mirror of
https://github.com/OMGeeky/flucto-heisskleber.git
synced 2025-12-27 06:29:32 +01:00
WIP: file sender.
This commit is contained in:
@@ -13,7 +13,6 @@ readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dynamic = ["version"]
|
||||
dependencies= [
|
||||
"aiofiles>=24.1.0",
|
||||
"aiomqtt>=2.3.0",
|
||||
"pyserial>=3.5",
|
||||
"pyyaml>=6.0.2",
|
||||
@@ -112,7 +111,9 @@ ignore = [
|
||||
"COM812",
|
||||
"ISC001",
|
||||
"ARG001",
|
||||
"INP001"
|
||||
"INP001",
|
||||
"TRY003", # Avoid specifying long messages in Exceptions
|
||||
"EM101", # Exceptions must not use string literal
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from .config import FileConf
|
||||
from .receiver import FileReceiver
|
||||
from .sender import FileWriter
|
||||
|
||||
__all__ = ["FileConf", "FileReceiver", "FileWriter"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from io import BufferedWriter
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import aiofiles
|
||||
import aiofiles.threadpool
|
||||
|
||||
from heisskleber.core import Packer, Sender, json_packer
|
||||
from heisskleber.file.config import FileConf
|
||||
|
||||
@@ -20,7 +20,7 @@ class FileWriter(Sender[T]):
|
||||
Files are named according to the configured datetime format.
|
||||
"""
|
||||
|
||||
def __init__(self, base_path: Path | str, config: FileConf, packer: Packer[T] = json_packer) -> None:
|
||||
def __init__(self, config: FileConf, packer: Packer[T] = json_packer) -> None: # type: ignore[assignment]
|
||||
"""Initialize the file writer.
|
||||
|
||||
Args:
|
||||
@@ -28,11 +28,14 @@ class FileWriter(Sender[T]):
|
||||
config: Configuration for file rollover and naming
|
||||
packer: Optional packer for serializing data
|
||||
"""
|
||||
self.base_path = Path(base_path)
|
||||
self.base_path = Path(config.directory)
|
||||
self.config = config
|
||||
self.packer = packer
|
||||
|
||||
self._current_file = aiofiles.open("test.txt")
|
||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
self._current_file: BufferedWriter | None = None
|
||||
self._rollover_task: asyncio.Task | None = None
|
||||
self._last_rollover: float = 0
|
||||
|
||||
@@ -40,47 +43,57 @@ class FileWriter(Sender[T]):
|
||||
"""Generate filename based on current timestamp."""
|
||||
return self.base_path / datetime.now().strftime(self.config.name_fmt)
|
||||
|
||||
async def _open_file(self, filename: Path) -> BufferedWriter:
|
||||
"""Open file asynchronously."""
|
||||
return await self._loop.run_in_executor(self._executor, lambda: filename.open(mode="ba", buffering=1))
|
||||
|
||||
async def _close_file(self) -> None:
|
||||
if self._current_file is not None:
|
||||
await self._loop.run_in_executor(self._executor, self._current_file.close)
|
||||
|
||||
async def _write_to_file(self, data: str) -> None:
|
||||
"""Write to file asynchronously via executor."""
|
||||
if not self._current_file:
|
||||
raise RuntimeError("No open file!")
|
||||
await self._loop.run_in_executor(self._executor, lambda: self._current_file.write(data))
|
||||
|
||||
async def _rollover(self) -> None:
|
||||
"""Close current file and open a new one."""
|
||||
if self._current_file is not None:
|
||||
await self._current_file.close()
|
||||
await self._close_file()
|
||||
|
||||
filename = self._get_filename()
|
||||
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._current_file = await aiofiles.open(filename, mode="a")
|
||||
self._last_rollover = asyncio.get_event_loop().time()
|
||||
logging.info(f"Rolled over to new file: {filename}")
|
||||
self._current_file = await self._open_file(filename)
|
||||
self._last_rollover = self._loop.time()
|
||||
logging.info("Rolled over to new file: %s", filename)
|
||||
|
||||
async def _rollover_loop(self) -> None:
|
||||
"""Background task that handles periodic file rollover."""
|
||||
while True:
|
||||
try:
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - self._last_rollover >= self.config.rollover:
|
||||
await self._rollover()
|
||||
await asyncio.sleep(1) # Check every second
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logging.error(f"Error in rollover loop: {e}")
|
||||
await asyncio.sleep(1) # Avoid tight loop on error
|
||||
now = self._loop.time()
|
||||
if now - self._last_rollover >= self.config.rollover:
|
||||
await self._rollover()
|
||||
await asyncio.sleep(1) # Check every second
|
||||
|
||||
async def send(self, data: str, **kwargs: Any) -> None:
|
||||
async def send(self, data: T, **kwargs: Any) -> None:
|
||||
"""Write data to the current file.
|
||||
|
||||
Args:
|
||||
data: String data to write
|
||||
data: Data to write
|
||||
**kwargs: Additional arguments (unused)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If writer hasn't been started
|
||||
"""
|
||||
if self._current_file is None:
|
||||
if not self._rollover_task:
|
||||
await self.start()
|
||||
if not self._current_file:
|
||||
raise RuntimeError("FileWriter not started")
|
||||
|
||||
packed_data = self.packer.pack(data)
|
||||
await self._current_file.write(packed_data + "\n")
|
||||
await self._current_file.flush()
|
||||
payload = self.packer(data)
|
||||
await self._loop.run_in_executor(self._executor, self._current_file.write, payload)
|
||||
await self._loop.run_in_executor(self._executor, self._current_file.write, b"\n")
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the file writer and rollover background task."""
|
||||
@@ -91,15 +104,15 @@ class FileWriter(Sender[T]):
|
||||
"""Stop the writer and cleanup resources."""
|
||||
if self._rollover_task:
|
||||
self._rollover_task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._rollover_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._rollover_task = None
|
||||
|
||||
if self._current_file:
|
||||
await self._current_file.close()
|
||||
await self._close_file()
|
||||
self._current_file = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return string representation of FileWriter."""
|
||||
status = "started" if self._current_file else "stopped"
|
||||
return f"AsyncFileWriter(path='{self.base_path}', status={status})"
|
||||
return f"FileWriter(path='{self.base_path}', status={status})"
|
||||
|
||||
41
tests/file/test_file_operations.py
Normal file
41
tests/file/test_file_operations.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
# from freezegun import freeze_time
|
||||
from heisskleber.file import FileConf, FileWriter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return FileConf(
|
||||
rollover=3600, # 1 hour rollover
|
||||
name_fmt="%Y%m%d_%H.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_writer_basic_operations(config: FileConf) -> None:
|
||||
"""Test basic file operations: open, write, close."""
|
||||
writer = FileWriter(config)
|
||||
|
||||
# Test starting the writer
|
||||
await writer.start()
|
||||
assert writer._current_file is not None
|
||||
assert writer._rollover_task is not None
|
||||
|
||||
# Test writing data
|
||||
test_data = {"message": "hello world"}
|
||||
await writer.send(test_data)
|
||||
|
||||
# Test file content
|
||||
current_file = writer._get_filename()
|
||||
assert current_file.exists()
|
||||
|
||||
await writer.stop()
|
||||
assert writer._current_file is None
|
||||
assert writer._rollover_task is None
|
||||
|
||||
# Verify file content after closing
|
||||
content = current_file.read_text().split("\n")[0]
|
||||
assert content == json.dumps(test_data)
|
||||
13
uv.lock
generated
13
uv.lock
generated
@@ -1,15 +1,6 @@
|
||||
version = 1
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "24.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiomqtt"
|
||||
version = "2.3.0"
|
||||
@@ -388,10 +379,9 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "heisskleber"
|
||||
version = "1.0.3.dev0"
|
||||
version = "1.0.3.dev1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiomqtt" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyyaml" },
|
||||
@@ -437,7 +427,6 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "aiomqtt", specifier = ">=2.3.0" },
|
||||
{ name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = ">=7.6.1" },
|
||||
{ name = "furo", marker = "extra == 'docs'", specifier = ">=2024.8.6" },
|
||||
|
||||
Reference in New Issue
Block a user