From fd7fef85d123781a611f17c22b4ae2837d00a07e Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:57:11 +0200 Subject: [PATCH 1/9] Updated module and method names --- Tests/Performance/concurency_test.py | 2 +- Tests/cli_test.py | 8 ++++---- Tests/data_test.py | 10 +++++----- Tests/{parser_test.py => fetcher_test.py} | 8 ++++---- pyproject.toml | 2 +- src/logstats/cli.py | 2 +- src/logstats/core.py | 4 ++-- src/logstats/data.py | 2 +- src/logstats/{parser.py => fetcher.py} | 0 src/logstats/report.py | 6 +++--- src/logstats/schemas.py | 6 +++--- src/logstats/source_parser.py | 2 +- src/logstats/stats.py | 6 +++--- 13 files changed, 29 insertions(+), 29 deletions(-) rename Tests/{parser_test.py => fetcher_test.py} (87%) rename src/logstats/{parser.py => fetcher.py} (100%) diff --git a/Tests/Performance/concurency_test.py b/Tests/Performance/concurency_test.py index 73a892f..f9c921f 100644 --- a/Tests/Performance/concurency_test.py +++ b/Tests/Performance/concurency_test.py @@ -3,7 +3,7 @@ import httpx -from logstats.parser import collect +from logstats.fetcher import collect from logstats.source_parser import FetchedSource diff --git a/Tests/cli_test.py b/Tests/cli_test.py index 76f2ae8..dbfa02f 100644 --- a/Tests/cli_test.py +++ b/Tests/cli_test.py @@ -3,7 +3,7 @@ import pytest from click.testing import CliRunner -from logstats.cli import parse_aguments +from logstats.cli import parse_arguments @pytest.fixture @@ -15,7 +15,7 @@ def sample_log(tmp_path): def test_cli_doesnt_allow_multiple_reports(sample_log): result = CliRunner().invoke( - parse_aguments, [str(sample_log), "--top", "1", "--per-hour"] + parse_arguments, [str(sample_log), "--top", "1", "--per-hour"] ) assert result.exit_code != 0 assert "can't be used together" in result.output @@ -24,13 +24,13 @@ def test_cli_doesnt_allow_multiple_reports(sample_log): def test_cli_no_logs_available(sample_log, caplog): with caplog.at_level(logging.INFO): result = CliRunner().invoke( - parse_aguments, [str(sample_log), "--level", "WARNING"] + parse_arguments, [str(sample_log), "--level", "WARNING"] ) assert result.exit_code == 0 assert "No logs" in caplog.text def test_cli_end_to_end(sample_log): - result = CliRunner().invoke(parse_aguments, [str(sample_log), "--top", "1"]) + result = CliRunner().invoke(parse_arguments, [str(sample_log), "--top", "1"]) assert result.exit_code == 0 assert "Message" in result.output diff --git a/Tests/data_test.py b/Tests/data_test.py index 9b7bd89..749bc32 100644 --- a/Tests/data_test.py +++ b/Tests/data_test.py @@ -4,16 +4,16 @@ from hypothesis import given from hypothesis import strategies as st -from logstats.data import InvalidLogFormat, LogEntry, LogType, get_name_capitalize +from logstats.data import InvalidLogFormat, LogEntry, LogType, level_label @given(level=st.sampled_from(LogType)) -def test_get_name_capitalize_when_enum_values_used(level): - assert get_name_capitalize(level) == level.name.capitalize() +def test_level_label_when_enum_values_used(level): + assert level_label(level) == level.name.capitalize() -def test_get_name_capitalize_when_none_used(): - assert get_name_capitalize(None) == "All" +def test_level_label_when_none_used(): + assert level_label(None) == "All" def test_produce_log_entry(): diff --git a/Tests/parser_test.py b/Tests/fetcher_test.py similarity index 87% rename from Tests/parser_test.py rename to Tests/fetcher_test.py index efc8dcd..9298093 100644 --- a/Tests/parser_test.py +++ b/Tests/fetcher_test.py @@ -2,7 +2,7 @@ import httpx -from logstats.parser import collect +from logstats.fetcher import collect from logstats.source_parser import FetchedSource @@ -21,7 +21,7 @@ def test_parse_fetches(): } result = asyncio.run(collect_logs(logs)) assert {r.source for r in result} == set(logs) - assert (sum(len(r.log_lines) for r in result)) == 4 + assert (sum(len(r.entries) for r in result)) == 4 def test_unknown_source_types(): @@ -30,7 +30,7 @@ def test_unknown_source_types(): } result = asyncio.run(collect_logs(logs)) assert {r.source for r in result} == set(logs) - assert (sum(len(r.log_lines) for r in result)) == 0 + assert (sum(len(r.entries) for r in result)) == 0 def test_connectivity_issues(): @@ -46,5 +46,5 @@ async def run() -> list[FetchedSource]: ) by_source = {r.source: r for r in asyncio.run(run())} - assert len(by_source["http://x/good.log"].log_lines) == 1 # good one still parsed + assert len(by_source["http://x/good.log"].entries) == 1 # good one still parsed assert by_source["http://x/bad.log"].error is not None # bad one flagged, not fatal diff --git a/pyproject.toml b/pyproject.toml index 6d9540e..6f546e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ requires = ["uv_build >= 0.11.23, <0.12.0"] build-backend = "uv_build" [project.scripts] -logstats="logstats.cli:parse_aguments" +logstats="logstats.cli:parse_arguments" [tool.pytest.ini_options] testpaths = ["Tests"] diff --git a/src/logstats/cli.py b/src/logstats/cli.py index ccd337c..1f407c6 100644 --- a/src/logstats/cli.py +++ b/src/logstats/cli.py @@ -20,7 +20,7 @@ @click.option( "-c", "combined", is_flag=True, help="combine reports from different sources" ) -def parse_aguments( +def parse_arguments( sources: Sequence[str], level: LogType | None, top: int | None, diff --git a/src/logstats/core.py b/src/logstats/core.py index 33c99cc..352d8b1 100644 --- a/src/logstats/core.py +++ b/src/logstats/core.py @@ -4,7 +4,7 @@ from functools import partial from logstats.data import LogType, ReportRequest -from logstats.parser import fetch +from logstats.fetcher import fetch from logstats.per_hour_stats import compute_per_hour from logstats.report import Report, format_hourly, format_regular, format_top from logstats.source_parser import FetchedSource @@ -47,7 +47,7 @@ def main( if len(fetched_sources) == 0: logger.warning("No available sources.") return - if all(not fs.log_lines for fs in fetched_sources): + if all(not fs.entries for fs in fetched_sources): logger.info("No logs available with the selected filters.") return for report in create_reports(fetched_sources, request): diff --git a/src/logstats/data.py b/src/logstats/data.py index 581f536..3c6d122 100644 --- a/src/logstats/data.py +++ b/src/logstats/data.py @@ -14,7 +14,7 @@ class LogType(Enum): ERROR = 3 -def get_name_capitalize(level: LogType | None) -> str: +def level_label(level: LogType | None) -> str: return level.name.capitalize() if level is not None else "All" diff --git a/src/logstats/parser.py b/src/logstats/fetcher.py similarity index 100% rename from src/logstats/parser.py rename to src/logstats/fetcher.py diff --git a/src/logstats/report.py b/src/logstats/report.py index 0f975b6..dc9a5ad 100644 --- a/src/logstats/report.py +++ b/src/logstats/report.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from logstats.data import LogEntry, get_name_capitalize +from logstats.data import LogEntry, level_label from logstats.per_hour_stats import HourlyStats from logstats.top_stats import TopStats @@ -23,7 +23,7 @@ def __str__(self) -> str: def format_top(stats: TopStats) -> Report: return Report( stats.source, - f"Top {stats.top} {get_name_capitalize(stats.level)} messages:", + f"Top {stats.top} {level_label(stats.level)} messages:", [ f" {m.count} x {m.level.name.capitalize()}: {m.message}" for m in stats.messages @@ -34,7 +34,7 @@ def format_top(stats: TopStats) -> Report: def format_hourly(stats: HourlyStats) -> Report: return Report( stats.source, - f"{get_name_capitalize(stats.level)} messages per hour:", + f"{level_label(stats.level)} messages per hour:", [ f" {m.timestamp.date()} {m.timestamp.hour:02d}:00 {m.count}" for m in stats.messages diff --git a/src/logstats/schemas.py b/src/logstats/schemas.py index f7fb477..3c42de2 100644 --- a/src/logstats/schemas.py +++ b/src/logstats/schemas.py @@ -7,7 +7,7 @@ import httpx from pydantic import BaseModel -from logstats.data import LogType, get_name_capitalize +from logstats.data import LogType, level_label from logstats.per_hour_stats import HourlyStats, compute_per_hour from logstats.source_parser import FetchedSource, stream_entries from logstats.stats import gather_stats @@ -47,7 +47,7 @@ def to_top_response( results=[ TopStatsOut( source=s.source, - level=get_name_capitalize(s.level), + level=level_label(s.level), top=s.top, total=s.total, messages=[ @@ -110,7 +110,7 @@ def top_per_hour_response( results=[ HourlyStatsOut( source=s.source, - level=get_name_capitalize(s.level), + level=level_label(s.level), total=s.total, messages=[ HouryMessageOut(timestamp=m.timestamp, count=m.count) diff --git a/src/logstats/source_parser.py b/src/logstats/source_parser.py index a9e6d3a..a9eac29 100644 --- a/src/logstats/source_parser.py +++ b/src/logstats/source_parser.py @@ -20,7 +20,7 @@ class SourceType(Enum): @dataclass class FetchedSource: - log_lines: list[LogEntry] + entries: list[LogEntry] source: str error: str | None = None diff --git a/src/logstats/stats.py b/src/logstats/stats.py index f7b902f..dda17d2 100644 --- a/src/logstats/stats.py +++ b/src/logstats/stats.py @@ -5,14 +5,14 @@ import httpx from logstats.data import LogEntry, LogType -from logstats.parser import collect +from logstats.fetcher import collect from logstats.source_parser import FetchedSource T = TypeVar("T") def merge_entries(sources: Sequence[FetchedSource]) -> list[LogEntry]: - return [line for fs in sources for line in fs.log_lines] + return [line for fs in sources for line in fs.entries] def build_stats( @@ -23,7 +23,7 @@ def build_stats( if combined: return [compute(merge_entries(sources), "All")] else: - return [compute(fs.log_lines, fs.source) for fs in sources] + return [compute(fs.entries, fs.source) for fs in sources] async def gather_stats( From a6909f0b3a920912ecc38d945f82bc2e9b801f1b Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:02:14 +0200 Subject: [PATCH 2/9] Added health check --- src/logstats/api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/logstats/api.py b/src/logstats/api.py index 3d1db32..8d6ac63 100644 --- a/src/logstats/api.py +++ b/src/logstats/api.py @@ -87,3 +87,8 @@ async def get_regular( return StreamingResponse( stream_all(urls, level, client), media_type="text/event-stream" ) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} From 11f9f1571fbfcd883972e12431cfecfa597b557e Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:16:04 +0200 Subject: [PATCH 3/9] Fixed path resolving for K8S and updated tests --- Tests/api_test.py | 6 ++++++ Tests/config_test.py | 21 ++++++++++++++++++++- src/logstats/config.py | 18 +++++++++++++++--- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Tests/api_test.py b/Tests/api_test.py index 7462a4a..6ad748f 100644 --- a/Tests/api_test.py +++ b/Tests/api_test.py @@ -41,6 +41,12 @@ def api(): app.dependency_overrides.clear() +def test_health_reports_ok(api): + response = api.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + def test_app_boots_and_serves(tmp_path, monkeypatch): log = tmp_path / "app.log" log.write_text(APP1_LOGS) diff --git a/Tests/config_test.py b/Tests/config_test.py index b61274e..33449bc 100644 --- a/Tests/config_test.py +++ b/Tests/config_test.py @@ -1,9 +1,10 @@ import tomllib +from pathlib import Path import pytest from pydantic import ValidationError -from logstats.config import load_settings +from logstats.config import DEFAULT_SOURCES, load_settings, resolve_sources_path def write_toml(tmp_path, content: str): @@ -12,6 +13,24 @@ def write_toml(tmp_path, content: str): return path +def test_relative_path_becomes_absolute(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_sources_path(Path("custom.toml")) == tmp_path / "custom.toml" + + +def test_falls_back_to_default_name_without_env_var(tmp_path, monkeypatch): + monkeypatch.delenv("LOGSTATS_SOURCES", raising=False) + monkeypatch.chdir(tmp_path) + assert resolve_sources_path() == tmp_path / DEFAULT_SOURCES + + +def test_symlinks_are_not_followed(tmp_path): + target = write_toml(tmp_path, '[sources]\napp1 = "sample.log"\n') + link = tmp_path / "link.toml" + link.symlink_to(target) + assert resolve_sources_path(link) == link + + def test_loads_named_sources(tmp_path): path = write_toml(tmp_path, '[sources]\napp1 = "http://logs.test/app1.log"\n') settings = load_settings(path) diff --git a/src/logstats/config.py b/src/logstats/config.py index 43e71f7..8911700 100644 --- a/src/logstats/config.py +++ b/src/logstats/config.py @@ -12,8 +12,20 @@ def get_source_url(self, name: str) -> str: return self.sources[name] -def load_settings(path: Path | None = None) -> Settings: +DEFAULT_SOURCES = "sources.toml" + + +def resolve_sources_path(path: Path | None = None) -> Path: + """Return the sources config as an absolute path, without following symlinks. + + Falls back to $LOGSTATS_SOURCES, then to sources.toml in the working directory. + """ if path is None: - path = Path(os.environ.get("LOGSTATS_SOURCES", "sources.toml")) - with path.open("rb") as f: + path = Path(os.environ.get("LOGSTATS_SOURCES", DEFAULT_SOURCES)) + return path.absolute() + + +def load_settings(path: Path | None = None) -> Settings: + resolved = resolve_sources_path(path) + with resolved.open("rb") as f: return Settings(**tomllib.load(f)) From 68e7467be32131b304cfe508e687d96fb71260bb Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:19:21 +0200 Subject: [PATCH 4/9] Added explicit ConfigError --- Tests/config_test.py | 30 ++++++++++++++++++++---------- src/logstats/config.py | 15 ++++++++++++--- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/Tests/config_test.py b/Tests/config_test.py index 33449bc..65cd765 100644 --- a/Tests/config_test.py +++ b/Tests/config_test.py @@ -1,10 +1,14 @@ -import tomllib +import re from pathlib import Path import pytest -from pydantic import ValidationError -from logstats.config import DEFAULT_SOURCES, load_settings, resolve_sources_path +from logstats.config import ( + DEFAULT_SOURCES, + ConfigError, + load_settings, + resolve_sources_path, +) def write_toml(tmp_path, content: str): @@ -44,16 +48,22 @@ def test_path_defaults_to_env_var(tmp_path, monkeypatch): @pytest.mark.parametrize( - "content, expected_error", + "content", [ - ('name = "logstats"\n', ValidationError), - ('sources = "not-a-table"\n', ValidationError), - ("[sources]\napp1 = 42\n", ValidationError), - ("[sources\napp1 = broken", tomllib.TOMLDecodeError), + 'name = "logstats"\n', + 'sources = "not-a-table"\n', + "[sources]\napp1 = 42\n", + "[sources\napp1 = broken", ], ids=["missing-table", "sources-not-a-table", "url-not-a-string", "malformed-toml"], ) -def test_invalid_config_is_rejected(tmp_path, content, expected_error): +def test_invalid_config_is_rejected(tmp_path, content): path = write_toml(tmp_path, content) - with pytest.raises(expected_error): + with pytest.raises(ConfigError, match=re.escape(str(path))): load_settings(path) + + +def test_missing_config_reports_the_path(tmp_path): + missing = tmp_path / "nope.toml" + with pytest.raises(ConfigError, match=re.escape(str(missing))): + load_settings(missing) diff --git a/src/logstats/config.py b/src/logstats/config.py index 8911700..f810282 100644 --- a/src/logstats/config.py +++ b/src/logstats/config.py @@ -2,7 +2,11 @@ import tomllib from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError + + +class ConfigError(Exception): + """Raised when the sources config cannot be read or is not valid.""" class Settings(BaseModel): @@ -27,5 +31,10 @@ def resolve_sources_path(path: Path | None = None) -> Path: def load_settings(path: Path | None = None) -> Settings: resolved = resolve_sources_path(path) - with resolved.open("rb") as f: - return Settings(**tomllib.load(f)) + try: + with resolved.open("rb") as f: + return Settings(**tomllib.load(f)) + except OSError as exc: + raise ConfigError(f"Cannot read sources config at {resolved}: {exc}") from exc + except (tomllib.TOMLDecodeError, ValidationError) as exc: + raise ConfigError(f"Invalid sources config at {resolved}: {exc}") from exc From 23e5af3c39c248896db596b207d5c48e8457cb37 Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:21:01 +0200 Subject: [PATCH 5/9] Fixed potential order issue for FastAPI --- src/logstats/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/logstats/api.py b/src/logstats/api.py index 8d6ac63..de034f3 100644 --- a/src/logstats/api.py +++ b/src/logstats/api.py @@ -27,7 +27,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app = FastAPI(lifespan=lifespan) router = APIRouter(prefix="/stats", tags=["stats"]) -app.include_router(router) def get_settings(request: Request) -> Settings: @@ -92,3 +91,6 @@ async def get_regular( @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} + + +app.include_router(router) From 04d50e92ae70c7019284e2efb8d6b32e467ba181 Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:22:37 +0200 Subject: [PATCH 6/9] Fixed few typos --- src/logstats/schemas.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/logstats/schemas.py b/src/logstats/schemas.py index 3c42de2..e604963 100644 --- a/src/logstats/schemas.py +++ b/src/logstats/schemas.py @@ -86,7 +86,7 @@ async def gather_top_stats( return to_top_response(stats, fetched) -class HouryMessageOut(BaseModel): +class HourlyMessageOut(BaseModel): timestamp: datetime count: int @@ -95,7 +95,7 @@ class HourlyStatsOut(BaseModel): source: str level: str total: int - messages: list[HouryMessageOut] + messages: list[HourlyMessageOut] class PerHourResponse(BaseModel): @@ -103,7 +103,7 @@ class PerHourResponse(BaseModel): errors: list[SourceError] = [] -def top_per_hour_response( +def to_per_hour_response( stats: list[HourlyStats], fetched: Sequence[FetchedSource] ) -> PerHourResponse: return PerHourResponse( @@ -113,7 +113,7 @@ def top_per_hour_response( level=level_label(s.level), total=s.total, messages=[ - HouryMessageOut(timestamp=m.timestamp, count=m.count) + HourlyMessageOut(timestamp=m.timestamp, count=m.count) for m in s.messages ], ) @@ -136,7 +136,7 @@ async def gather_per_hour_stats( (stats, fetched) = await gather_stats( sources, level, combined, client, partial(compute_per_hour, level=level) ) - return top_per_hour_response(stats, fetched) + return to_per_hour_response(stats, fetched) class SseEvent(BaseModel): From bb2a46f02d32cfa5725d6f4a7865251a3c697f9b Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:49:05 +0200 Subject: [PATCH 7/9] Added a concurency limit and timeouts --- Tests/fetcher_test.py | 22 ++++++++++++++++++++++ src/logstats/api.py | 3 ++- src/logstats/fetcher.py | 22 +++++++++++++++------- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/Tests/fetcher_test.py b/Tests/fetcher_test.py index 9298093..83296e1 100644 --- a/Tests/fetcher_test.py +++ b/Tests/fetcher_test.py @@ -33,6 +33,28 @@ def test_unknown_source_types(): assert (sum(len(r.entries) for r in result)) == 0 +def test_collect_never_exceeds_max_concurrent(): + in_flight = 0 + peak = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal in_flight, peak + in_flight += 1 + peak = max(peak, in_flight) + await asyncio.sleep(0.01) + in_flight -= 1 + return httpx.Response(200, text="2026-07-13T09:00:00 INFO ok") + + async def run() -> list[FetchedSource]: + sources = [f"http://x/app{i}.log" for i in range(8)] + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + return await collect(sources, None, client, max_concurrent=3) + + result = asyncio.run(run()) + assert len(result) == 8 + assert peak == 3 + + def test_connectivity_issues(): def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/good.log": diff --git a/src/logstats/api.py b/src/logstats/api.py index de034f3..7c3fa12 100644 --- a/src/logstats/api.py +++ b/src/logstats/api.py @@ -8,6 +8,7 @@ from logstats.config import Settings, load_settings from logstats.data import LogType +from logstats.fetcher import TIMEOUT from logstats.schemas import ( PerHourResponse, TopResponse, @@ -20,7 +21,7 @@ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.state.settings = load_settings() - app.state.client = httpx.AsyncClient() + app.state.client = httpx.AsyncClient(timeout=TIMEOUT) yield await app.state.client.aclose() diff --git a/src/logstats/fetcher.py b/src/logstats/fetcher.py index 99b6692..b653ce4 100644 --- a/src/logstats/fetcher.py +++ b/src/logstats/fetcher.py @@ -9,19 +9,27 @@ logger = logging.getLogger(__name__) +MAX_CONCURRENT_FETCHES = 10 +TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0) + async def fetch(sources: Sequence[str], level: LogType | None) -> list[FetchedSource]: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=TIMEOUT) as client: return await collect(sources, level, client) async def collect( - sources: Sequence[str], level: LogType | None, client: httpx.AsyncClient + sources: Sequence[str], + level: LogType | None, + client: httpx.AsyncClient, + max_concurrent: int = MAX_CONCURRENT_FETCHES, ) -> list[FetchedSource]: - results: list[asyncio.Task[FetchedSource]] = [] + limit = asyncio.Semaphore(max_concurrent) + + async def fetch_one(source: str) -> FetchedSource: + async with limit: + return await fetch_source(source, level, client) + async with asyncio.TaskGroup() as group: - for s in sources: - task = fetch_source(s, level, client) - if task is not None: - results.append(group.create_task(task)) + results = [group.create_task(fetch_one(s)) for s in sources] return [t.result() for t in results] From f86cc9a2bb35cad049f814e4f0b5f5b98c5b4410 Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:07:21 +0200 Subject: [PATCH 8/9] Switch to iterable instead of Sequence --- src/logstats/per_hour_stats.py | 6 +++--- src/logstats/top_stats.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/logstats/per_hour_stats.py b/src/logstats/per_hour_stats.py index 82ce30d..a7c5489 100644 --- a/src/logstats/per_hour_stats.py +++ b/src/logstats/per_hour_stats.py @@ -1,5 +1,5 @@ from collections import Counter -from collections.abc import Sequence +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, time @@ -21,13 +21,13 @@ class HourlyStats: def compute_per_hour( - entries: Sequence[LogEntry], source: str, *, level: LogType | None + entries: Iterable[LogEntry], source: str, *, level: LogType | None ) -> HourlyStats: messages = Counter((e.timestamp.date(), e.timestamp.hour) for e in entries) return HourlyStats( source, level, - len(entries), + sum(messages.values()), [ HourMessage(datetime.combine(day, time(hour=hour)), n) for (day, hour), n in sorted(messages.items()) diff --git a/src/logstats/top_stats.py b/src/logstats/top_stats.py index d4aeeac..bbaabe8 100644 --- a/src/logstats/top_stats.py +++ b/src/logstats/top_stats.py @@ -1,5 +1,5 @@ from collections import Counter -from collections.abc import Sequence +from collections.abc import Iterable from dataclasses import dataclass from logstats.data import LogEntry, LogType @@ -22,13 +22,13 @@ class TopStats: def compute_top( - entries: Sequence[LogEntry], source: str, *, level: LogType | None, top: int + entries: Iterable[LogEntry], source: str, *, level: LogType | None, top: int ) -> TopStats: messages = Counter((e.level, e.message) for e in entries) return TopStats( source, top, level, - len(entries), + sum(messages.values()), [MessageCount(lvl, msg, n) for (lvl, msg), n in messages.most_common(top)], ) From 8b5afd4e00e75700305ef73cca8425e5d3daef03 Mon Sep 17 00:00:00 2001 From: Camarent <3629354+Camarent@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:09:25 +0200 Subject: [PATCH 9/9] Updated README --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f611198..f4862eb 100644 --- a/README.md +++ b/README.md @@ -120,18 +120,19 @@ Interactive docs render at [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/do ### Endpoints -All endpoints accept repeated `sources` params and an optional `level`. +The `/stats/*` endpoints accept repeated `sources` params and an optional `level`. | Endpoint | Returns | | --- | --- | | `GET /stats/top` | Top-N message counts (JSON) | | `GET /stats/per-hour` | Per-hour entry counts (JSON) | | `GET /stats/regular` | Raw entries as a Server-Sent Events stream | +| `GET /health` | `{"status": "ok"}` — liveness check, takes no params | | Query param | Applies to | Description | | --- | --- | --- | -| `sources` | all | Registered source name; repeat for multiple | -| `level` | all | `DEBUG`/`INFO`/`WARNING`/`ERROR` (case-insensitive) | +| `sources` | all `/stats/*` | Registered source name; repeat for multiple | +| `level` | all `/stats/*` | `DEBUG`/`INFO`/`WARNING`/`ERROR` (case-insensitive) | | `top` | `/stats/top` | Number of messages to return (≥ 1, default 3) | | `combined` | `/stats/top`, `/stats/per-hour` | Merge all sources into one result (default `false`) |