WIP: file sender.

This commit is contained in:
Felix Weiler-Detjen
2025-01-13 17:19:37 +00:00
parent c69a9ec051
commit 6facd5e8a7
5 changed files with 94 additions and 45 deletions

View File

@@ -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]

View File

@@ -0,0 +1,5 @@
from .config import FileConf
from .receiver import FileReceiver
from .sender import FileWriter
__all__ = ["FileConf", "FileReceiver", "FileWriter"]

View File

@@ -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})"

View 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
View File

@@ -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" },