From 2b26344eb49892a0dd98debbf7e74b9612e11fdf Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 15:58:48 +1100 Subject: [PATCH 01/21] fix: bound evicted patterns and fuzzy index memory Pattern mode kept a result for every Drain cluster it ever created, so max_clusters bounded the template store but not the memory around it. And the fuzzy LSH at enhanced and maximum never dropped an entry. Both grew with the number of distinct lines: 10x the distinct input gave about 10x the tracemalloc peak (28.5 MB against 2.9 MB at standard, 124.5 MB against 11.8 MB at enhanced). - An evicted cluster's result now waits only until the dedup cache can no longer raise its count, then competes for one of max_patterns places. One that loses can never win a place back, so the output is the same as ranking every template ever seen. - The fuzzy index holds the last dedup_cache_size admitted lines, dropping the oldest first, the same window exact dedup keeps. FuzzyDeduplicator takes it as max_entries. - Output is unchanged on 264 reduce runs (11 corpora, 4 modes, 3 levels, default and max_clusters=7). It changes only once more than dedup_cache_size near-unique lines go through fuzzy dedup. - README, architecture and pypi docs now say what bounds pattern-mode memory. Fixes #23 --- README.md | 11 +-- docs/architecture.md | 5 +- docs/pypi.md | 4 +- src/logreducer/config.py | 1 + src/logreducer/core.py | 2 +- src/logreducer/patterns.py | 136 +++++++++++++++++++++++++++++++----- tests/unit/test_core.py | 34 +++++++-- tests/unit/test_patterns.py | 103 +++++++++++++++++++++++++++ 8 files changed, 264 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 721799a..025199d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It ships as a CLI and as a Python library with an IO-agnostic core. ## What it gives you - A `logreducer` command that reduces a file, a SQL or ClickHouse query, or a Kafka topic, straight from the shell. -- Pattern mode streams through a bounded dedup cache and template store, so memory grows with the number of distinct templates, not with the line count. +- Pattern mode streams through a bounded dedup cache, fuzzy index and template store, so memory does not grow with the line count or the number of distinct lines. - Four modes: pattern (Drain3 template mining), anomaly (Isolation Forest), temporal (one example per template per time window) and hybrid. - ERROR, FATAL and CRITICAL lines always come through, one per distinct shape and ahead of the reduced output, in every mode and however a file is read. A SQL or ClickHouse source samples in the database first, so only the lines it returns are seen. - A core with no IO dependencies: anything re-iterable that yields `str` lines is a source, and anything with `write(lines)` is a sink. @@ -88,7 +88,7 @@ reduced = reducer.reduce(lines) ## How it works -Pattern mode streams: lines flow through dedup and template mining as one generator pipeline, so the unique-line set is never materialised. Memory grows with the number of distinct templates, since a result is kept for every template seen, not with the line count. +Pattern mode streams: lines flow through dedup and template mining as one generator pipeline, so the unique-line set is never materialised. A template the store evicts keeps its place in the ranking only while it can still make the `max_patterns` cut, so memory is set by `max_clusters`, `max_patterns` and `dedup_cache_size`, not by the input. ```mermaid flowchart LR @@ -223,10 +223,11 @@ logreducer --dsn postgresql://user@host/db --query "SELECT msg FROM logs" \ ## Bounding memory -Pattern mode streams, and its memory grows with distinct templates, not lines. Temporal mode holds the whole source in memory, and anomaly mode holds its unique lines. The knobs: +Pattern mode streams, and its memory is fixed by the knobs below, not by lines or distinct templates. Temporal mode holds the whole source in memory, and anomaly mode holds its unique lines. The knobs: - `max_memory_gb` - the overall cap (also sizes file read strategy and the watchdog). Presets: 0.5 / 1 / 2 GB by level; values above 70% of available RAM are clamped with a warning. -- `max_clusters` - LRU-bound the Drain3 template store (10,000 by default, `None` for unbounded). Mining time grows with the square of the number of templates a line is compared with, so a lower value also speeds up high-cardinality sources. +- `dedup_cache_size` - the lines exact dedup remembers, and at enhanced and maximum the lines the fuzzy index holds. Both drop the oldest first, so a repeat or near-repeat older than the window comes through again. +- `max_clusters` - LRU-bound the Drain3 template store (10,000 by default, `None` for unbounded, which also keeps every evicted template's result). Mining time grows with the square of the number of templates a line is compared with, so a lower value also speeds up high-cardinality sources. - `anomaly_max_rows` - reservoir-cap the rows fed to anomaly detection (off by default; trades anomaly recall for a bounded matrix). ```python @@ -307,7 +308,7 @@ reducer = LogReducer( level="enhanced", mode="hybrid", max_memory_gb=1.0, # memory ceiling (clamped to 70% of available RAM) - dedup_cache_size=100000, # bounded dedup cache + dedup_cache_size=100000, # bounded dedup cache and fuzzy index drain_similarity=0.4, # pattern similarity threshold fuzzy_threshold=0.8, # fuzzy-dedup threshold (enhanced/maximum) anomaly_contamination=0.1, # expected anomaly fraction diff --git a/docs/architecture.md b/docs/architecture.md index e5078e6..536af67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,8 +66,9 @@ than pass one, and the reducer would be comparing two populations. Pattern mode streams end to end. Exact dedup, optional fuzzy dedup and Drain3 mining run as one generator pipeline, so the unique-line set is never collected -into a list. Peak memory is the bounded dedup cache plus the Drain3 template -store, and neither grows with the number of unique lines the source has. +into a list. Peak memory is the bounded dedup cache, the fuzzy index (the same `dedup_cache_size` window), the Drain3 template store and the results of evicted templates, and none of them grows with the number of unique lines the source has. + +An evicted template's result is kept only while it can still make the `max_patterns` cut. Its count stops changing once the dedup cache has dropped the lines that fed it, so from then on it competes for one of `max_patterns` places, and a result that loses one can never win it back. That keeps the output identical to ranking every template ever seen. Drain3 compares each line with every template stored at the leaf it reaches, so time grows with the square of the template count when lines share no tokens. diff --git a/docs/pypi.md b/docs/pypi.md index 5eec1f4..93ade0f 100644 --- a/docs/pypi.md +++ b/docs/pypi.md @@ -7,7 +7,7 @@ It ships as a CLI and as a Python library with an IO-agnostic core. ## What it gives you - A `logreducer` command that reduces a file, a SQL or ClickHouse query, or a Kafka topic, straight from the shell. -- Pattern mode streams through a bounded dedup cache and template store, so memory grows with the number of distinct templates, not with the line count. +- Pattern mode streams through a bounded dedup cache, fuzzy index and template store, so memory does not grow with the line count or the number of distinct lines. - A memory budget that decides how a file is read: whole, in chunks, or reservoir-sampled once it passes five times the budget. - Four modes: pattern (Drain3 template mining), anomaly (Isolation Forest), temporal (one example per template per time window) and hybrid. - ERROR, FATAL and CRITICAL lines always come through, one per distinct shape and ahead of the reduced output, in every mode and however a file is read. A SQL or ClickHouse source samples in the database first, so only the lines it returns are seen. @@ -48,7 +48,7 @@ From the shell it is `logreducer app.log -o reduced.log`. `level` is `standard`, | Extra | Adds | |---|---| -| `enhanced` | Fuzzy dedup with MinHash LSH, whose index grows with near-unique lines, and faster hashing | +| `enhanced` | Fuzzy dedup with MinHash LSH, whose index holds the last `dedup_cache_size` near-unique lines, and faster hashing | | `sql` | `SQLSource` over SQLAlchemy, with your own DBAPI driver | | `clickhouse` | `ClickHouseSource` over clickhouse-connect | | `kafka` | `KafkaSource` and `KafkaSink` over confluent-kafka | diff --git a/src/logreducer/config.py b/src/logreducer/config.py index 983db43..8ea3bd9 100644 --- a/src/logreducer/config.py +++ b/src/logreducer/config.py @@ -47,6 +47,7 @@ class BigDialConfig: # measured reductions use tens of MB, so the default is deliberately low # and container-friendly. max_memory_gb: float = 1.0 + # Lines exact dedup remembers, and lines the fuzzy index holds. Both drop the oldest first. dedup_cache_size: int = 100000 # Speed Control diff --git a/src/logreducer/core.py b/src/logreducer/core.py index 3254f9f..c6c4a95 100644 --- a/src/logreducer/core.py +++ b/src/logreducer/core.py @@ -124,7 +124,7 @@ def _reset_components(self) -> None: self.deduplicator = BoundedDeduplicator(self.config.dedup_cache_size, self.config.hash_algorithm) self.pattern_extractor = PatternExtractor(self.config) if self.config.fuzzy_threshold and self.level != ProcessingLevel.STANDARD: - self.fuzzy_dedup = FuzzyDeduplicator(self.config.fuzzy_threshold) + self.fuzzy_dedup = FuzzyDeduplicator(self.config.fuzzy_threshold, self.config.dedup_cache_size) else: self.fuzzy_dedup = None if self.mode in [ProcessingMode.TEMPORAL, ProcessingMode.HYBRID]: diff --git a/src/logreducer/patterns.py b/src/logreducer/patterns.py index 4df114b..f5872d9 100644 --- a/src/logreducer/patterns.py +++ b/src/logreducer/patterns.py @@ -1,6 +1,8 @@ """Pattern extraction (Drain3 template mining) and fuzzy deduplication.""" +import heapq import logging +from collections import deque from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -172,6 +174,59 @@ def pattern_priority(template: str, count: int, severe: bool = False) -> float: return priority +class _RetiredPatterns: + """Patterns whose cluster was LRU-evicted, holding only those the capped ranking can still return. + + A retired pattern's template no longer changes, and its count grows only while the dedup + cache still holds an ``Occurrence`` pointing at it. That cache is FIFO, so ``window`` lines + after the last one that carried an ``Occurrence``, the count is final. Final patterns compete + for the ``cap`` places the output has, and one that loses can never win one back. Severe + patterns are all kept, as the cap does not apply to them. + """ + + def __init__(self, cap: int, min_count: int, window: int) -> None: + self._cap = cap + self._min_count = min_count + self._window = window + self._severe: list[tuple[int, LogPattern]] = [] + # Min-heap on (priority, -cluster_id): the root is the one the final sort ranks last. + self._best: list[tuple[float, int, LogPattern]] = [] + # Min-heap on the line number of the pattern's last Occurrence. + self._counting: list[tuple[int, int, LogPattern]] = [] + + def add(self, cluster_id: int, pattern: LogPattern, last_occurrence: int | None) -> None: + """Retire the pattern of an evicted cluster, given the line number of its last ``Occurrence``.""" + if pattern.severe: + self._severe.append((cluster_id, pattern)) + elif last_occurrence is None: + self._offer(cluster_id, pattern) + else: + heapq.heappush(self._counting, (last_occurrence, cluster_id, pattern)) + + def advance(self, line_number: int) -> None: + """Rank every retired pattern whose count can no longer change by ``line_number``.""" + while self._counting and line_number - self._counting[0][0] >= self._window: + _, cluster_id, pattern = heapq.heappop(self._counting) + self._offer(cluster_id, pattern) + + def _offer(self, cluster_id: int, pattern: LogPattern) -> None: + if pattern.count < self._min_count: + return + heapq.heappush(self._best, (_priority(pattern), -cluster_id, pattern)) + if len(self._best) > self._cap: + heapq.heappop(self._best) + + def items(self) -> list[tuple[int, LogPattern]]: + """Return every retired pattern that could still reach the output, keyed by cluster id.""" + best = [(-negative_id, pattern) for _, negative_id, pattern in self._best] + counting = [(cluster_id, pattern) for _, cluster_id, pattern in self._counting] + return self._severe + best + counting + + +def _priority(pattern: LogPattern) -> float: + return pattern_priority(pattern.template, pattern.count, pattern.severe) + + class PatternExtractor: """Extract patterns using the online template miner (see template_mining).""" @@ -199,8 +254,9 @@ def extract_patterns(self, lines: Iterable[str]) -> list[LogPattern]: """Extract patterns from lines (accepts a stream; only clusters are held). Feeds each line into the online miner, so the caller can pass a - generator - no need to materialise the whole line set. Memory is bounded - by the number of clusters (see ``max_clusters``), not the line count. + generator - no need to materialise the whole line set. With ``max_clusters`` + set, memory is bounded by it and by ``max_patterns``, not by the line count + or the number of distinct templates. """ return self.extract_counted_patterns((line, None) for line in lines) @@ -209,11 +265,21 @@ def extract_counted_patterns(self, pairs: Iterable[tuple[str, Occurrence | None] Repeats that exact dedup drops after a line was kept still count towards the pattern that line joined, so 100 identical errors make one pattern - with ``count == 100``. + with ``count == 100``. The ``Occurrence`` objects must come from a dedup + cache of at most ``dedup_cache_size`` lines: an evicted pattern's count is + taken as final that many lines after its last ``Occurrence``. """ pattern_map: dict[int, LogPattern] = {} + last_occurrence: dict[int, int] = {} + retired = _RetiredPatterns( + self.config.max_patterns, self.config.min_pattern_occurrences, self.config.dedup_cache_size + ) + max_clusters = self.config.max_clusters + # Sweeping at twice the store's bound costs O(1) per line, amortised. + sweep_at = None if max_clusters is None else 2 * max_clusters - for line, occurrence in pairs: + for line_number, (line, occurrence) in enumerate(pairs): + retired.advance(line_number) cluster = self.miner.add_log_message(line) pattern = pattern_map.get(cluster.cluster_id) if pattern is None: @@ -223,10 +289,14 @@ def extract_counted_patterns(self, pairs: Iterable[tuple[str, Occurrence | None] pattern.count += 1 if occurrence is not None: occurrence.target = pattern + last_occurrence[cluster.cluster_id] = line_number if len(pattern.examples) < self.config.examples_per_pattern: pattern.examples.append(line) + if sweep_at is not None and len(pattern_map) >= sweep_at: + self._retire_evicted(pattern_map, last_occurrence, retired) + # Later lines widen a cluster's slots to <*>, so the stored template is # re-read once the feed ends; an LRU-evicted cluster keeps its snapshot. for cluster_id, pattern in pattern_map.items(): @@ -234,7 +304,9 @@ def extract_counted_patterns(self, pairs: Iterable[tuple[str, Occurrence | None] if live_cluster is not None: pattern.template = live_cluster.get_template() - patterns = list(pattern_map.values()) + # Cluster ids rise in creation order, which the stable sort below breaks ties on. + candidates = sorted([*pattern_map.items(), *retired.items()], key=lambda item: item[0]) + patterns = [pattern for _, pattern in candidates] # Apply filters patterns = self._filter_by_occurrence(patterns) @@ -244,6 +316,14 @@ def extract_counted_patterns(self, pairs: Iterable[tuple[str, Occurrence | None] patterns.sort(key=lambda p: p.priority, reverse=True) return cap_outside_severe(patterns, self.config.max_patterns, lambda p: p.severe) + def _retire_evicted( + self, pattern_map: dict[int, LogPattern], last_occurrence: dict[int, int], retired: _RetiredPatterns + ) -> None: + """Move the patterns of clusters the miner has evicted out of ``pattern_map``.""" + evicted = [cluster_id for cluster_id in pattern_map if self.miner.get_cluster(cluster_id) is None] + for cluster_id in evicted: + retired.add(cluster_id, pattern_map.pop(cluster_id), last_occurrence.pop(cluster_id, None)) + def _filter_by_occurrence(self, patterns: list[LogPattern]) -> list[LogPattern]: """Filter patterns by minimum occurrence, keeping every CRITICAL, FATAL or ERROR one.""" return [p for p in patterns if p.count >= self.config.min_pattern_occurrences or p.severe] @@ -257,10 +337,29 @@ def _calculate_priority(self, patterns: list[LogPattern]) -> list[LogPattern]: class FuzzyDeduplicator: - """Fuzzy (near-duplicate) deduplication using MinHash LSH.""" + """Fuzzy (near-duplicate) deduplication using MinHash LSH. - def __init__(self, threshold: float = 0.8): + With ``max_entries`` set, the index holds the most recently admitted lines and drops the + oldest first, the same sliding window ``BoundedDeduplicator`` keeps for exact repeats. A + near-duplicate of a line that has left the window is admitted again. + """ + + def __init__(self, threshold: float = 0.8, max_entries: int | None = None): + """Create a deduplicator. + + Args: + threshold: Jaccard similarity at or above which a line is a near-duplicate. + max_entries: Admitted lines the index holds. ``None`` keeps every one. + + Raises: + ValueError: If ``max_entries`` is below 1. + """ + if max_entries is not None and max_entries < 1: + raise ValueError(f"max_entries must be at least 1 or None, got {max_entries!r}") self.threshold = threshold + self.max_entries = max_entries + self._keys: deque[int] = deque() + self._next_key = 0 self.enabled = MINHASH_AVAILABLE if self.enabled: @@ -272,15 +371,15 @@ def deduplicate_stream(self, lines: Iterable[str]) -> Iterator[str]: """Yield near-unique lines as they arrive (streaming near-dup filter). Queries the LSH per line and inserts only new ones, so the reducer never - materialises the full unique-line list. Note the LSH itself grows with - the number of near-unique lines - that is inherent to fuzzy dedup. + materialises the full unique-line list. The index grows with near-unique + lines up to ``max_entries``. """ if not self.enabled: yield from lines return - for i, line in enumerate(lines): - if self._admit(i, line): + for line in lines: + if self._admit(line): yield line def deduplicate_pairs(self, pairs: Iterable[tuple[str, Occurrence]]) -> Iterator[tuple[str, Occurrence]]: @@ -289,19 +388,24 @@ def deduplicate_pairs(self, pairs: Iterable[tuple[str, Occurrence]]) -> Iterator yield from pairs return - for i, pair in enumerate(pairs): - if self._admit(i, pair[0]): + for pair in pairs: + if self._admit(pair[0]): yield pair - def _admit(self, index: int, line: str) -> bool: - """True (and remembered) when no near-duplicate of ``line`` was admitted before.""" + def _admit(self, line: str) -> bool: + """True (and remembered) when no near-duplicate of ``line`` is in the index.""" m = MinHash(num_perm=64) for word in line.split(): m.update(word.encode("utf-8", "surrogatepass")) if self.lsh.query(m): return False - self.lsh.insert(f"line_{index}", m) + if self.max_entries is not None and len(self._keys) >= self.max_entries: + self.lsh.remove(self._keys.popleft()) + key = self._next_key + self._next_key += 1 + self.lsh.insert(key, m) + self._keys.append(key) return True def deduplicate(self, lines: Iterable[str]) -> list[str]: diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 1b111f8..6a4f064 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -659,12 +659,25 @@ def __iter__(self): ) -def _pattern_flood_run(count: int) -> tuple[list[str], int]: +class _QuietFlood: + """A re-iterable source of ``count`` quiet lines that share no word, so each is a new template and fuzzy-unique.""" + + def __init__(self, count: int) -> None: + self.count = count + + def __iter__(self): + return ( + f"svc{i} req{i * 7919 % 100003} user{i * 31} took{i % 977} path{i * 13} ok{i * 17}" + for i in range(self.count) + ) + + +def _pattern_flood_run(source, level: str = "standard", count: int = 5_000) -> tuple[list[str], int]: tracemalloc.start() try: - out = LogReducer(mode="pattern", max_clusters=5, dedup_cache_size=1000, typed_masking=False).reduce( - _SevereFlood(count) - ) + out = LogReducer( + level=level, mode="pattern", max_clusters=5, dedup_cache_size=1000, typed_masking=False + ).reduce(source(count)) return out, tracemalloc.get_traced_memory()[1] finally: tracemalloc.stop() @@ -672,8 +685,17 @@ def _pattern_flood_run(count: int) -> tuple[list[str], int]: def test_severe_flood_of_one_shape_gives_one_line_in_bounded_memory(): """Severe lines are held by the source tap alone, so the pattern miner's store does not grow with them.""" - _, small_peak = _pattern_flood_run(5_000) - out, large_peak = _pattern_flood_run(50_000) + _, small_peak = _pattern_flood_run(_SevereFlood, count=5_000) + out, large_peak = _pattern_flood_run(_SevereFlood, count=50_000) assert out == ["ERROR 0 0 0 0 0 0"] assert large_peak < small_peak * 1.5 + + +@pytest.mark.parametrize("level", ["standard", "enhanced", "maximum"]) +def test_quiet_flood_of_new_templates_runs_in_bounded_memory(level): + """Evicted clusters and the fuzzy index are bounded, so 10x the distinct lines costs no more memory.""" + _, small_peak = _pattern_flood_run(_QuietFlood, level, count=5_000) + _, large_peak = _pattern_flood_run(_QuietFlood, level, count=50_000) + + assert large_peak < small_peak * 1.5 diff --git a/tests/unit/test_patterns.py b/tests/unit/test_patterns.py index f68eb41..aa54f6a 100644 --- a/tests/unit/test_patterns.py +++ b/tests/unit/test_patterns.py @@ -2,6 +2,7 @@ Unit tests for pattern extraction module """ +import tracemalloc from unittest.mock import Mock, patch import pytest @@ -243,6 +244,108 @@ def test_max_clusters_bounds_template_store(self): extractor.extract_patterns(lines) assert len(extractor.miner.drain.id_to_cluster) <= 5 + def test_fuzzy_index_holds_at_most_max_entries(self): + dedup = FuzzyDeduplicator(max_entries=100) + if not dedup.enabled: + pytest.skip("datasketch not installed") + + kept = list(dedup.deduplicate_stream(_near_unique_lines(1_000))) + + assert len(kept) == 1_000 + assert len(dedup.lsh.keys) == 100 + + def test_near_duplicate_past_the_window_is_admitted_again(self): + dedup = FuzzyDeduplicator(max_entries=2) + if not dedup.enabled: + pytest.skip("datasketch not installed") + first, second, third = _near_unique_lines(3) + + kept = list(dedup.deduplicate_stream([first, first, second, third, first])) + + assert kept == [first, second, third, first] + + @pytest.mark.parametrize("max_entries", [0, -1]) + def test_fuzzy_window_must_hold_one_entry(self, max_entries): + with pytest.raises(ValueError, match="max_entries"): + FuzzyDeduplicator(max_entries=max_entries) + + +def _near_unique_lines(count: int): + """``count`` lines that share no word, so no two are near-duplicates.""" + return (f"svc{i} req{i * 7} user{i * 13} took{i * 17} path{i * 19} ok{i * 23}" for i in range(count)) + + +def _peak_bytes(run) -> int: + tracemalloc.start() + try: + run() + return tracemalloc.get_traced_memory()[1] + finally: + tracemalloc.stop() + + +def _distinct_templates(count: int): + """``count`` quiet lines whose first token differs, so each founds its own Drain cluster.""" + return (f"svc{i} request{i * 7919 % 100003} took {i % 977}ms path{i}" for i in range(count)) + + +class TestPeakMemoryDoesNotGrowWithDistinctLines: + """Ten times the distinct input costs no more memory once the bounds are full.""" + + def test_pattern_store_is_bounded_across_cluster_evictions(self): + def run(count: int) -> None: + PatternExtractor(BigDialConfig(max_clusters=5, min_pattern_occurrences=1)).extract_patterns( + _distinct_templates(count) + ) + + assert _peak_bytes(lambda: run(50_000)) < _peak_bytes(lambda: run(5_000)) * 1.5 + + def test_fuzzy_index_is_bounded(self): + if not FuzzyDeduplicator().enabled: + pytest.skip("datasketch not installed") + + def run(count: int) -> None: + for _ in FuzzyDeduplicator(max_entries=1_000).deduplicate_stream(_near_unique_lines(count)): + pass + + assert _peak_bytes(lambda: run(20_000)) < _peak_bytes(lambda: run(2_000)) * 1.5 + + +def _evicting_feed(rounds: int) -> list[str]: + """Hot lines repeated between bursts of new templates, so their clusters are evicted while they still count.""" + lines = [] + for burst in range(rounds): + lines.append("INFO heartbeat ok") + lines.append(f"INFO tick {burst % 4} done") + lines.extend(f"svc{burst}x{n} started worker{n}" for n in range(6)) + lines.append(f"svc{burst}x0 started worker0") + return lines + + +class TestBoundedRankingKeepsTheSameAnswer: + """The capped result is the prefix of the uncapped one, so bounding the store changes nothing it returns.""" + + @pytest.mark.parametrize("window", [8, 1_000]) + @pytest.mark.parametrize("cap", [1, 2, 5, 40]) + @pytest.mark.parametrize("max_clusters", [1, 3, 25]) + def test_capped_is_prefix_of_uncapped(self, cap, max_clusters, window): + lines = _evicting_feed(60) + + def extract(max_patterns: int) -> list[tuple[str, int]]: + config = BigDialConfig(max_clusters=max_clusters, max_patterns=max_patterns, dedup_cache_size=window) + counted = BoundedDeduplicator(window).deduplicate_counted(lines) + return [(p.template, p.count) for p in PatternExtractor(config).extract_counted_patterns(counted)] + + assert extract(cap) == extract(10**9)[:cap] + + def test_evicted_pattern_that_keeps_counting_still_ranks_first(self): + config = BigDialConfig(max_clusters=3, max_patterns=1) + counted = BoundedDeduplicator(1_000).deduplicate_counted(_evicting_feed(60)) + + patterns = PatternExtractor(config).extract_counted_patterns(counted) + + assert [(p.template, p.count) for p in patterns] == [("INFO heartbeat ok", 60)] + class TestTypedMasking: """Opt-in typed Drain3 masking (config.typed_masking).""" From 02b05b8dbe47a076c3ba412832fada7dd6bf5ca3 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 16:02:08 +1100 Subject: [PATCH 02/21] docs: state the fuzzy index memory per preset --- docs/architecture.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 536af67..6d816ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,6 +70,8 @@ into a list. Peak memory is the bounded dedup cache, the fuzzy index (the same ` An evicted template's result is kept only while it can still make the `max_patterns` cut. Its count stops changing once the dedup cache has dropped the lines that fed it, so from then on it competes for one of `max_patterns` places, and a result that loses one can never win it back. That keeps the output identical to ranking every template ever seen. +The fuzzy index measured about 1.8 KB per entry, so the preset windows hold about 180 MB at enhanced (100K lines) and 360 MB at maximum (200K), inside their 1 GB and 2 GB budgets. + Drain3 compares each line with every template stored at the leaf it reaches, so time grows with the square of the template count when lines share no tokens. `max_clusters` (10,000 by default) bounds the template store and with it the From 567895e4e5b95a15b500cdec428c4d54e51cb503 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 16:04:42 +1100 Subject: [PATCH 03/21] fix: stop a Kafka read at the end offsets it started with KafkaSource read until every partition had reported EOF, and never cleared an EOF. A topic written to during the read was read past its start-time end, so passes of one reduce saw different messages, and a partition that took new writes after its EOF still counted as drained. Each partition's high watermark is now recorded when it is assigned, the read stops there by offset or by EOF, and messages past it wait for the next read. Read against a Redpanda broker: 2,000 lines plus 50 written mid-read used to return 2,050. --- src/logreducer/kafka.py | 71 +++++++++++++++++------- tests/integration/test_kafka_adapters.py | 30 ++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/logreducer/kafka.py b/src/logreducer/kafka.py index 74eaac6..48780cd 100644 --- a/src/logreducer/kafka.py +++ b/src/logreducer/kafka.py @@ -13,10 +13,10 @@ be finite and re-iterable (the reducer counts lines by exhausting the source, and re-reads it for multi-pass modes): -* **Bounded.** The read stops when every assigned partition has reached its end - (``enable.partition.eof`` -> ``_PARTITION_EOF``), which is the deterministic - "caught up with the topic" signal. A consecutive-idle-poll count is only a - backstop for the never-assigned case (e.g. a missing topic). +* **Bounded.** The read records each partition's end offset when it is assigned + and stops once every partition has reached it, by offset or by + ``_PARTITION_EOF``. Messages written after that point wait for the next read. + A consecutive-idle-poll count is only a backstop for a stalled broker. * **No commit.** Offsets are never committed, so each pass re-reads from the earliest offset - that is what makes the source re-iterable. An application that needs offset tracking should consume itself and hand the reducer its own @@ -25,8 +25,6 @@ Install: ``pip install 'logreducer[kafka]'``. """ -from __future__ import annotations - import importlib.util import time from collections.abc import Iterable, Iterator @@ -53,8 +51,8 @@ "max.poll.interval.ms": 300000, # 5 minutes max poll interval "fetch.min.bytes": 1, # Return immediately with any data "fetch.wait.max.ms": 500, # Max wait for fetch.min.bytes - # logreducer-specific: needed so a drained partition reports EOF, which is - # how the bounded read knows it has caught up with the topic. + # A partition that ends in records the consumer skips (transaction markers, compacted + # offsets) is known to be read through only by its EOF event. "enable.partition.eof": True, } @@ -119,9 +117,9 @@ def _normalise_config(config: str | dict[str, Any]) -> dict[str, Any]: class KafkaSource: """A bounded, re-iterable stream of log lines from a Kafka topic. - Each iteration creates a fresh consumer, subscribes, and reads until every - assigned partition reports end-of-partition (i.e. the topic is drained), - decoding each message value to a ``str`` line. Offsets are never committed, + Each iteration creates a fresh consumer, subscribes, and reads every partition + up to the end offset it had when assigned, decoding each message value to a + ``str`` line. Offsets are never committed, so every pass re-reads from the earliest offset. """ @@ -172,7 +170,10 @@ def __iter__(self) -> Iterator[str]: consumer = Consumer(self._config) consumer.subscribe(self.topics) - eof_partitions: set[tuple[str, int]] = set() + # End offset of each partition when first assigned: the read stops there, so a write + # made during it is left for the next pass. + end_offsets: dict[tuple[str, int], int] = {} + drained: set[tuple[str, int]] = set() empty_polls = 0 emitted = 0 # Never-assigned backstop: an unreachable broker, a missing topic, or an @@ -189,8 +190,12 @@ def __iter__(self) -> Iterator[str]: msg = consumer.poll(self.poll_timeout) - if consumer.assignment(): + assignment = consumer.assignment() + if assignment: ever_assigned = True + self._record_end_offsets(consumer, assignment, end_offsets, drained) + if drained >= end_offsets.keys(): + break elif not ever_assigned and time.monotonic() >= assignment_deadline: raise KafkaConsumerError( f"no partition assignment within {self.assignment_timeout:.0f}s for " @@ -201,26 +206,28 @@ def __iter__(self) -> Iterator[str]: # Before partitions are assigned an empty poll just means the # group is still joining - don't count it. Once assigned, # repeated empties are the backstop for a never-EOF broker. - if consumer.assignment(): + if assignment: empty_polls += 1 if empty_polls >= self.idle_polls: break continue empty_polls = 0 + partition_key = (msg.topic(), msg.partition()) error = msg.error() if error: if error.code() == KafkaError._PARTITION_EOF: - topic, partition = msg.topic(), msg.partition() - if topic is not None and partition is not None: - eof_partitions.add((topic, partition)) - assigned = {(tp.topic, tp.partition) for tp in consumer.assignment()} - # Drained: every partition we hold has reported its end. - if assigned and eof_partitions >= assigned: - break + drained.add(partition_key) continue raise KafkaConsumerError(f"Kafka error: {error.str()}", error_code=error.code()) + end = end_offsets.get(partition_key) + offset = msg.offset() + if end is not None and offset is not None and offset >= end - 1: + drained.add(partition_key) + if offset >= end: + continue + value = msg.value() if value is None: continue @@ -231,6 +238,28 @@ def __iter__(self) -> Iterator[str]: finally: consumer.close() + def _record_end_offsets( + self, + consumer: Any, + assignment: list[Any], + end_offsets: dict[tuple[str, int], int], + drained: set[tuple[str, int]], + ) -> None: + """Record the high watermark of each newly assigned partition, marking empty ones drained.""" + from confluent_kafka import KafkaException + + for tp in assignment: + key = (tp.topic, tp.partition) + if key in end_offsets: + continue + try: + low, high = consumer.get_watermark_offsets(tp, timeout=self.assignment_timeout, cached=False) + except KafkaException as exc: + raise KafkaConsumerError(f"cannot read the end offset of {key!r}: {exc}") from exc + end_offsets[key] = high + if high <= low: + drained.add(key) + def __repr__(self) -> str: return f"KafkaSource(topics={self.topics!r}, config={mask_credentials(self._config)!r})" diff --git a/tests/integration/test_kafka_adapters.py b/tests/integration/test_kafka_adapters.py index 96f35ac..e102482 100644 --- a/tests/integration/test_kafka_adapters.py +++ b/tests/integration/test_kafka_adapters.py @@ -44,3 +44,33 @@ def test_kafka_sink_then_source_roundtrip(kafka_bootstrap): result = reducer.reduce(source) assert 0 < len(result) < 30 assert reducer.stats["input_lines"] == 30 + + +def _name() -> str: + return f"logreducer-it-{uuid.uuid4().hex[:8]}" + + +def _config(bootstrap, **overrides) -> dict: + config = {"bootstrap.servers": bootstrap} if isinstance(bootstrap, str) else dict(bootstrap) + return {**config, **overrides} + + +# The consumer holds about 1 KB locally, so a paused read is far from the end of the topic. +THROTTLED = {"queued.max.messages.kbytes": 1, "max.partition.fetch.bytes": 1024} + + +def test_source_stops_at_the_end_offsets_it_started_with(kafka_bootstrap): + from logreducer.kafka import KafkaSink, KafkaSource + + topic = _name() + lines = [f"INFO first batch {i}" for i in range(2_000)] + KafkaSink(kafka_bootstrap, topic).write(lines) + source = KafkaSource(_config(kafka_bootstrap, **THROTTLED), group_id=_name(), topics=topic) + + consumed = [] + for line in source: + if not consumed: + KafkaSink(kafka_bootstrap, topic).write([f"INFO written during the read {i}" for i in range(50)]) + consumed.append(line) + + assert consumed == lines From c24721477569cad27164c4d147d91e1abcad76bb Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 16:06:28 +1100 Subject: [PATCH 04/21] fix: raise when a Kafka read stalls short of its end Three consecutive empty polls ended a KafkaSource read as if it were complete, so a slow fetch handed the reducer a partial topic with no sign anything was missing. Against a Redpanda broker a 1 ms poll_timeout returned no lines at all from a 2,000-line topic. Empty polls no longer end a read. It finishes only at the recorded end offsets, and raises KafkaConsumerError once idle_timeout seconds (30 by default) pass with no message while a partition is short of its end. idle_timeout replaces idle_polls. The only caller passing idle_polls was this repo's own integration test, edited here. --- src/logreducer/kafka.py | 40 ++++++++++++------------ tests/integration/test_kafka_adapters.py | 26 ++++++++++++++- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/logreducer/kafka.py b/src/logreducer/kafka.py index 48780cd..53a2e5b 100644 --- a/src/logreducer/kafka.py +++ b/src/logreducer/kafka.py @@ -16,7 +16,8 @@ * **Bounded.** The read records each partition's end offset when it is assigned and stops once every partition has reached it, by offset or by ``_PARTITION_EOF``. Messages written after that point wait for the next read. - A consecutive-idle-poll count is only a backstop for a stalled broker. + A read that goes ``idle_timeout`` seconds without a message short of those + offsets raises, so a partial read never looks like a complete one. * **No commit.** Offsets are never committed, so each pass re-reads from the earliest offset - that is what makes the source re-iterable. An application that needs offset tracking should consume itself and hand the reducer its own @@ -132,7 +133,7 @@ def __init__( verify_ssl: bool = True, max_messages: int | None = None, poll_timeout: float = 1.0, - idle_polls: int = 3, + idle_timeout: float = 30.0, assignment_timeout: float = 30.0, encoding: str = "utf-8", ) -> None: @@ -145,8 +146,9 @@ def __init__( verify_ssl: If False, disable TLS certificate verification. max_messages: Optional hard cap on lines read per pass. poll_timeout: Seconds to block per ``poll`` call. - idle_polls: Backstop for a never-EOF broker - once partitions are - assigned, stop after this many consecutive empty polls. + idle_timeout: Backstop for a stalled broker. Once partitions are + assigned, iteration raises if this many seconds pass with no + message or EOF while a partition is short of its end offset. assignment_timeout: Backstop for the never-assigned case (unreachable broker, missing topic, auth failure). If no partition is assigned within this many seconds, iteration raises instead of hanging. @@ -161,7 +163,7 @@ def __init__( self.topics = [topics] if isinstance(topics, str) else list(topics) self.max_messages = max_messages self.poll_timeout = poll_timeout - self.idle_polls = idle_polls + self.idle_timeout = idle_timeout self.assignment_timeout = assignment_timeout self.encoding = encoding @@ -174,15 +176,12 @@ def __iter__(self) -> Iterator[str]: # made during it is left for the next pass. end_offsets: dict[tuple[str, int], int] = {} drained: set[tuple[str, int]] = set() - empty_polls = 0 emitted = 0 - # Never-assigned backstop: an unreachable broker, a missing topic, or an - # auth failure leaves assignment() empty forever, so empty_polls (gated - # on assignment) never fires and the loop would spin indefinitely. Bail - # if no partition is ever assigned within the timeout - a stuck - # dependency surfaced as a real error, not a silent hang. + # An unreachable broker, a missing topic or an auth failure leaves assignment() empty + # forever, so a read that is never assigned raises after assignment_timeout. ever_assigned = False assignment_deadline = time.monotonic() + self.assignment_timeout + last_event = time.monotonic() try: while True: if self.max_messages is not None and emitted >= self.max_messages: @@ -192,7 +191,9 @@ def __iter__(self) -> Iterator[str]: assignment = consumer.assignment() if assignment: - ever_assigned = True + if not ever_assigned: + ever_assigned = True + last_event = time.monotonic() self._record_end_offsets(consumer, assignment, end_offsets, drained) if drained >= end_offsets.keys(): break @@ -203,15 +204,14 @@ def __iter__(self) -> Iterator[str]: ) if msg is None: - # Before partitions are assigned an empty poll just means the - # group is still joining - don't count it. Once assigned, - # repeated empties are the backstop for a never-EOF broker. - if assignment: - empty_polls += 1 - if empty_polls >= self.idle_polls: - break + if ever_assigned and time.monotonic() - last_event >= self.idle_timeout: + short = sorted(end_offsets.keys() - drained) + raise KafkaConsumerError( + f"read stopped short of the end: no message for {self.idle_timeout:.0f}s " + f"with partitions {short!r} still below their end offsets" + ) continue - empty_polls = 0 + last_event = time.monotonic() partition_key = (msg.topic(), msg.partition()) error = msg.error() diff --git a/tests/integration/test_kafka_adapters.py b/tests/integration/test_kafka_adapters.py index e102482..ddf78d2 100644 --- a/tests/integration/test_kafka_adapters.py +++ b/tests/integration/test_kafka_adapters.py @@ -32,7 +32,6 @@ def test_kafka_sink_then_source_roundtrip(kafka_bootstrap): kafka_bootstrap, group_id=f"logreducer-it-{uuid.uuid4().hex[:8]}", topics=topic, - idle_polls=10, ) consumed = list(source) assert len(consumed) == 30 @@ -74,3 +73,28 @@ def test_source_stops_at_the_end_offsets_it_started_with(kafka_bootstrap): consumed.append(line) assert consumed == lines + + +def test_empty_polls_before_the_end_do_not_end_the_read(kafka_bootstrap): + from logreducer.kafka import KafkaSink, KafkaSource + + topic = _name() + lines = [f"INFO line {i}" for i in range(2_000)] + KafkaSink(kafka_bootstrap, topic).write(lines) + # A 1 ms poll returns empty between most fetches. + source = KafkaSource(_config(kafka_bootstrap, **THROTTLED), group_id=_name(), topics=topic, poll_timeout=0.001) + + assert list(source) == lines + + +def test_a_read_that_stalls_short_of_the_end_raises(kafka_bootstrap): + from logreducer.kafka import KafkaConsumerError, KafkaSink, KafkaSource + + topic = _name() + KafkaSink(kafka_bootstrap, topic).write([f"INFO line {i}" for i in range(10)]) + # The broker holds each fetch until 10 MB is ready or 20 s pass, so nothing arrives inside the idle timeout. + stalled = _config(kafka_bootstrap, **{"fetch.min.bytes": 10_000_000, "fetch.wait.max.ms": 20_000}) + source = KafkaSource(stalled, group_id=_name(), topics=topic, idle_timeout=2.0) + + with pytest.raises(KafkaConsumerError, match="stopped short of the end"): + list(source) From c467e52ba0a17c1501edfc3f1afa0b58f165fbfc Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 16:08:00 +1100 Subject: [PATCH 05/21] fix: retry a full Kafka producer queue and always flush KafkaSink.write let a BufferError from a full local queue escape on the first line past the queue limit, and any produce error skipped the flush, so lines already queued could be dropped when the producer was torn down. A full queue now serves delivery reports until there is room, for up to queue_full_timeout seconds (30 by default), then raises KafkaDeliveryError. The flush runs in a finally, and a flush that leaves messages undelivered still raises. Against a Redpanda broker: a 10-message queue used to fail on 2,000 lines, and an oversized line used to leave 5 earlier lines queued. --- src/logreducer/kafka.py | 40 +++++++++++++++++++----- tests/integration/test_kafka_adapters.py | 26 +++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/logreducer/kafka.py b/src/logreducer/kafka.py index 53a2e5b..5274c5d 100644 --- a/src/logreducer/kafka.py +++ b/src/logreducer/kafka.py @@ -280,6 +280,7 @@ def __init__( key: str | None = None, verify_ssl: bool = True, flush_timeout: float | None = None, + queue_full_timeout: float = 30.0, encoding: str = "utf-8", ) -> None: """Build a Kafka sink. @@ -290,6 +291,8 @@ def __init__( key: Optional fixed message key applied to every line. verify_ssl: If False, disable TLS certificate verification. flush_timeout: Seconds to wait on the final flush (None = infinite). + queue_full_timeout: Seconds one line waits for space in a full local + producer queue before ``write`` gives up. encoding: Text encoding for the produced message values. """ try: @@ -301,6 +304,7 @@ def __init__( self.topic = topic self.key = key self.flush_timeout = flush_timeout + self.queue_full_timeout = queue_full_timeout self.encoding = encoding self._producer = Producer(self._config) @@ -308,12 +312,13 @@ def write(self, lines: Iterable[str]) -> int: """Produce each line to the topic and flush; return the number delivered. With the default ``flush_timeout=None`` the final flush blocks until the - queue drains. + queue drains. The flush runs even when a produce raises, so lines queued + before the failure are delivered or counted. Raises: KafkaDeliveryError: A message was rejected by the broker, expired - before delivery, or was still queued when a finite - ``flush_timeout`` ran out. + before delivery, was still queued when a finite ``flush_timeout`` + ran out, or found the local queue full for ``queue_full_timeout``. """ key_bytes = self.key.encode(self.encoding) if self.key is not None else None produced = 0 @@ -323,11 +328,12 @@ def on_delivery(err: Any, _msg: Any) -> None: if err is not None: errors.append(str(err)) - for line in lines: - self._producer.produce(self.topic, value=line.encode(self.encoding), key=key_bytes, on_delivery=on_delivery) - self._producer.poll(0) # Trigger delivery callbacks (non-blocking) - produced += 1 - undelivered = self.flush() + try: + for line in lines: + self._produce(line.encode(self.encoding), key_bytes, on_delivery) + produced += 1 + finally: + undelivered = self.flush() if errors or undelivered: raise KafkaDeliveryError( f"{len(errors)} of {produced} messages failed and {undelivered} were still queued " @@ -337,6 +343,24 @@ def on_delivery(err: Any, _msg: Any) -> None: ) return produced + def _produce(self, value: bytes, key: bytes | None, on_delivery: Any) -> None: + """Queue one message, serving delivery reports to free space while the local queue is full.""" + deadline = time.monotonic() + self.queue_full_timeout + while True: + try: + self._producer.produce(self.topic, value=value, key=key, on_delivery=on_delivery) + break + except BufferError: + if time.monotonic() >= deadline: + queued = len(self._producer) + raise KafkaDeliveryError( + f"local producer queue stayed full for {self.queue_full_timeout:.0f}s for topic {self.topic!r}", + failed=0, + undelivered=queued, + ) from None + self._producer.poll(0.1) + self._producer.poll(0) + def flush(self) -> int: """Wait for outstanding messages; return the count still in queue.""" if self.flush_timeout is not None: diff --git a/tests/integration/test_kafka_adapters.py b/tests/integration/test_kafka_adapters.py index ddf78d2..fceb716 100644 --- a/tests/integration/test_kafka_adapters.py +++ b/tests/integration/test_kafka_adapters.py @@ -98,3 +98,29 @@ def test_a_read_that_stalls_short_of_the_end_raises(kafka_bootstrap): with pytest.raises(KafkaConsumerError, match="stopped short of the end"): list(source) + + +def test_sink_waits_for_queue_space_rather_than_failing(kafka_bootstrap): + from logreducer.kafka import KafkaSink, KafkaSource + + topic = _name() + lines = [f"INFO line {i}" for i in range(2_000)] + # A 10-message local queue fills long before the broker acknowledges anything. + sink = KafkaSink(_config(kafka_bootstrap, **{"queue.buffering.max.messages": 10}), topic) + + assert sink.write(lines) == 2_000 + assert sorted(KafkaSource(kafka_bootstrap, group_id=_name(), topics=topic)) == sorted(lines) + + +def test_sink_flushes_what_it_queued_when_a_produce_fails(kafka_bootstrap): + from confluent_kafka import KafkaException + + from logreducer.kafka import KafkaSink + + sink = KafkaSink(kafka_bootstrap, _name()) + oversized = "x" * 2_000_000 # over librdkafka's default message.max.bytes + + with pytest.raises(KafkaException): + sink.write([f"INFO line {i}" for i in range(5)] + [oversized]) + + assert len(sink._producer) == 0 From c5a8eea7bee1b94dfb072cf1bbb017eabdebef79 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:12:57 +1100 Subject: [PATCH 06/21] fix: type the Kafka partition key and the sink context msg.topic() and msg.partition() can be None, so the drained set took an untyped key, and dropping the PEP 563 import left KafkaSink.__enter__ naming its own class before it existed. Both now type-check under ty. --- src/logreducer/kafka.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/logreducer/kafka.py b/src/logreducer/kafka.py index 5274c5d..4183ddf 100644 --- a/src/logreducer/kafka.py +++ b/src/logreducer/kafka.py @@ -29,7 +29,7 @@ import importlib.util import time from collections.abc import Iterable, Iterator -from typing import Any +from typing import Any, Self # Production-lean librdkafka defaults: durable produces, bounded timeouts, # cheap batching. Any key can be overridden by the user config. @@ -52,8 +52,7 @@ "max.poll.interval.ms": 300000, # 5 minutes max poll interval "fetch.min.bytes": 1, # Return immediately with any data "fetch.wait.max.ms": 500, # Max wait for fetch.min.bytes - # A partition that ends in records the consumer skips (transaction markers, compacted - # offsets) is known to be read through only by its EOF event. + # Ends a partition whose last offsets hold no message (transaction markers, compaction). "enable.partition.eof": True, } @@ -120,8 +119,8 @@ class KafkaSource: Each iteration creates a fresh consumer, subscribes, and reads every partition up to the end offset it had when assigned, decoding each message value to a - ``str`` line. Offsets are never committed, - so every pass re-reads from the earliest offset. + ``str`` line. Offsets are never committed, so every pass re-reads from the + earliest offset. """ def __init__( @@ -213,13 +212,16 @@ def __iter__(self) -> Iterator[str]: continue last_event = time.monotonic() - partition_key = (msg.topic(), msg.partition()) + topic, partition = msg.topic(), msg.partition() error = msg.error() - if error: - if error.code() == KafkaError._PARTITION_EOF: - drained.add(partition_key) - continue + if error and error.code() != KafkaError._PARTITION_EOF: raise KafkaConsumerError(f"Kafka error: {error.str()}", error_code=error.code()) + if topic is None or partition is None: + continue + partition_key = (topic, partition) + if error: + drained.add(partition_key) + continue end = end_offsets.get(partition_key) offset = msg.offset() @@ -369,7 +371,7 @@ def flush(self) -> int: result = self._producer.flush() return result - def __enter__(self) -> KafkaSink: + def __enter__(self) -> Self: return self def __exit__(self, *exc: object) -> None: From 774cf6aad5f26f36f98e3dc6969d80c50baba275 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:22:49 +1100 Subject: [PATCH 07/21] fix: count fuzzy-dropped lines towards the pattern they matched Fuzzy dedup dropped a near-duplicate without telling anything, so neither it nor its later exact repeats counted. Two lines differing only in whitespace left the pattern at a count of one, and the default min_pattern_occurrences of 2 dropped it from the output. A dropped near-duplicate now adds one to the pattern of the admitted line it matched, and its Occurrence points there for later repeats. An evicted pattern's count is taken as final twice dedup_cache_size lines after its last Occurrence, since a matched near-duplicate can keep feeding it for one more window. This changes output at enhanced and maximum where near-duplicates were present: those patterns now carry their true counts. --- src/logreducer/patterns.py | 32 +++++++++++++++++++++++--------- tests/unit/test_patterns.py | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/logreducer/patterns.py b/src/logreducer/patterns.py index f5872d9..2fb4973 100644 --- a/src/logreducer/patterns.py +++ b/src/logreducer/patterns.py @@ -5,7 +5,7 @@ from collections import deque from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from .memory import Occurrence from .template_mining import MaskingInstruction, TemplateMiner, TemplateMinerConfig @@ -266,13 +266,15 @@ def extract_counted_patterns(self, pairs: Iterable[tuple[str, Occurrence | None] Repeats that exact dedup drops after a line was kept still count towards the pattern that line joined, so 100 identical errors make one pattern with ``count == 100``. The ``Occurrence`` objects must come from a dedup - cache of at most ``dedup_cache_size`` lines: an evicted pattern's count is - taken as final that many lines after its last ``Occurrence``. + cache, and any fuzzy index between, of at most ``dedup_cache_size`` lines: + an evicted pattern's count is taken as final twice that many lines after + its last ``Occurrence``, once a near-duplicate matched to it has also left + the dedup cache. """ pattern_map: dict[int, LogPattern] = {} last_occurrence: dict[int, int] = {} retired = _RetiredPatterns( - self.config.max_patterns, self.config.min_pattern_occurrences, self.config.dedup_cache_size + self.config.max_patterns, self.config.min_pattern_occurrences, 2 * self.config.dedup_cache_size ) max_clusters = self.config.max_clusters # Sweeping at twice the store's bound costs O(1) per line, amortised. @@ -359,6 +361,7 @@ def __init__(self, threshold: float = 0.8, max_entries: int | None = None): self.threshold = threshold self.max_entries = max_entries self._keys: deque[int] = deque() + self._occurrences: dict[int, Occurrence] = {} self._next_key = 0 self.enabled = MINHASH_AVAILABLE @@ -383,29 +386,40 @@ def deduplicate_stream(self, lines: Iterable[str]) -> Iterator[str]: yield line def deduplicate_pairs(self, pairs: Iterable[tuple[str, Occurrence]]) -> Iterator[tuple[str, Occurrence]]: - """``deduplicate_stream`` over (line, Occurrence) pairs, keeping each pair intact.""" + """``deduplicate_stream`` over (line, Occurrence) pairs, keeping each pair intact. + + A dropped near-duplicate, and every later exact repeat of it, counts towards the + pattern of the admitted line it matched. + """ if not self.enabled: yield from pairs return for pair in pairs: - if self._admit(pair[0]): + if self._admit(*pair): yield pair - def _admit(self, line: str) -> bool: + def _admit(self, line: str, occurrence: Occurrence | None = None) -> bool: """True (and remembered) when no near-duplicate of ``line`` is in the index.""" m = MinHash(num_perm=64) for word in line.split(): m.update(word.encode("utf-8", "surrogatepass")) - if self.lsh.query(m): + if matches := cast("list[int]", self.lsh.query(m)): + if occurrence is not None and (admitted := self._occurrences.get(min(matches))) is not None: + occurrence.target = admitted.target + admitted.repeat() return False if self.max_entries is not None and len(self._keys) >= self.max_entries: - self.lsh.remove(self._keys.popleft()) + evicted = self._keys.popleft() + self.lsh.remove(evicted) + self._occurrences.pop(evicted, None) key = self._next_key self._next_key += 1 self.lsh.insert(key, m) self._keys.append(key) + if occurrence is not None: + self._occurrences[key] = occurrence return True def deduplicate(self, lines: Iterable[str]) -> list[str]: diff --git a/tests/unit/test_patterns.py b/tests/unit/test_patterns.py index aa54f6a..e98e34d 100644 --- a/tests/unit/test_patterns.py +++ b/tests/unit/test_patterns.py @@ -477,6 +477,31 @@ def test_pattern_counts_include_dropped_repeats(self, lines, expected): def test_reduce_keeps_repeated_lines_and_drops_singletons(self, level, lines, expected): assert sorted(LogReducer(level=level).reduce(lines)) == sorted(expected) + @pytest.mark.parametrize( + ("lines", "expected"), + [ + pytest.param(["INFO disk check ok", "INFO disk check ok"], 2, id="near-duplicate"), + pytest.param(["INFO disk check ok"] + ["INFO disk check ok"] * 4, 5, id="repeats-of-a-near-duplicate"), + ], + ) + def test_near_duplicates_count_towards_the_pattern_they_matched(self, lines, expected): + fuzzy = FuzzyDeduplicator(max_entries=1_000) + if not fuzzy.enabled: + pytest.skip("datasketch not installed") + counted = fuzzy.deduplicate_pairs(BoundedDeduplicator(1_000).deduplicate_counted(lines)) + + patterns = PatternExtractor(BigDialConfig()).extract_counted_patterns(counted) + + assert [(p.template, p.count) for p in patterns] == [("INFO disk check ok", expected)] + + def test_reduce_keeps_a_pattern_seen_only_as_near_duplicates(self): + if not FuzzyDeduplicator().enabled: + pytest.skip("datasketch not installed") + + assert LogReducer(level="enhanced").reduce(["INFO disk check ok", "INFO disk check ok"]) == [ + "INFO disk check ok" + ] + def test_repeat_past_the_dedup_window_counts_as_a_new_sighting(self): extractor = PatternExtractor(BigDialConfig()) lines = [ERROR_LINE, "filler a", "filler b", ERROR_LINE] From b1098b41ae8a2c51b4c0a04ac18306aa5cdc7d0c Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:24:10 +1100 Subject: [PATCH 08/21] fix: rewind every Kafka read to the earliest offset auto.offset.reset=earliest applies only to a group with no committed offset, so a KafkaSource on a group_id another consumer had committed for started there and skipped the history before it. Each assigned partition is now set to OFFSET_BEGINNING in on_assign. Against a Redpanda broker, a group committed at offset 50 of 100 used to return the last 50 lines. --- src/logreducer/kafka.py | 15 +++++++++++---- tests/integration/test_kafka_adapters.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/logreducer/kafka.py b/src/logreducer/kafka.py index 4183ddf..e01a1c1 100644 --- a/src/logreducer/kafka.py +++ b/src/logreducer/kafka.py @@ -18,8 +18,9 @@ ``_PARTITION_EOF``. Messages written after that point wait for the next read. A read that goes ``idle_timeout`` seconds without a message short of those offsets raises, so a partial read never looks like a complete one. -* **No commit.** Offsets are never committed, so each pass re-reads from the - earliest offset - that is what makes the source re-iterable. An application +* **No commit.** Offsets are never committed, and every assigned partition is + rewound to its earliest offset whatever the group has committed, so each pass + re-reads the whole topic - that is what makes the source re-iterable. An application that needs offset tracking should consume itself and hand the reducer its own iterable of ``str``. @@ -167,10 +168,16 @@ def __init__( self.encoding = encoding def __iter__(self) -> Iterator[str]: - from confluent_kafka import Consumer, KafkaError + from confluent_kafka import OFFSET_BEGINNING, Consumer, KafkaError + + def from_the_start(consumer: Any, partitions: list[Any]) -> None: + # A committed group offset would skip the history before it. + for tp in partitions: + tp.offset = OFFSET_BEGINNING + consumer.assign(partitions) consumer = Consumer(self._config) - consumer.subscribe(self.topics) + consumer.subscribe(self.topics, on_assign=from_the_start) # End offset of each partition when first assigned: the read stops there, so a write # made during it is left for the next pass. end_offsets: dict[tuple[str, int], int] = {} diff --git a/tests/integration/test_kafka_adapters.py b/tests/integration/test_kafka_adapters.py index fceb716..3800efd 100644 --- a/tests/integration/test_kafka_adapters.py +++ b/tests/integration/test_kafka_adapters.py @@ -75,6 +75,24 @@ def test_source_stops_at_the_end_offsets_it_started_with(kafka_bootstrap): assert consumed == lines +def test_source_reads_from_the_start_whatever_its_group_committed(kafka_bootstrap): + from confluent_kafka import Consumer, TopicPartition + + from logreducer.kafka import KafkaSink, KafkaSource + + topic, group = _name(), _name() + lines = [f"INFO line {i}" for i in range(100)] + KafkaSink(kafka_bootstrap, topic).write(lines) + # Another member of the group commits half way through the topic. + other = Consumer(_config(kafka_bootstrap, **{"group.id": group})) + try: + other.commit(offsets=[TopicPartition(topic, 0, 50)], asynchronous=False) + finally: + other.close() + + assert list(KafkaSource(kafka_bootstrap, group_id=group, topics=topic)) == lines + + def test_empty_polls_before_the_end_do_not_end_the_read(kafka_bootstrap): from logreducer.kafka import KafkaSink, KafkaSource From 89aba443e6a07b662f31c85806f10ea109b9dfda Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:37:08 +1100 Subject: [PATCH 09/21] fix: clamp and validate LogReducer config overrides LogReducer applied max_memory_gb, max_patterns and keyword overrides with setattr after the config was built, so the 70% of available RAM clamp in BigDialConfig.__post_init__ never saw them: LogReducer(max_memory_gb=999999) kept 999999 and sized file strategies and reservoirs from it. Overrides now go through dataclasses.replace, which re-runs validation and the clamp. --- src/logreducer/core.py | 19 +++++++++---------- tests/unit/test_config.py | 23 +++++++++++++++++------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/logreducer/core.py b/src/logreducer/core.py index c6c4a95..ffea651 100644 --- a/src/logreducer/core.py +++ b/src/logreducer/core.py @@ -12,7 +12,7 @@ from typing import Any, Literal, overload from .anomaly import AnomalyDetector -from .config import BigDialConfig, OutputFormat, ProcessingLevel, ProcessingMode, get_preset_config, validate_config +from .config import BigDialConfig, OutputFormat, ProcessingLevel, ProcessingMode, get_preset_config from .logging_config import get_logger, setup_logging from .memory import BoundedDeduplicator, MemoryMonitor from .patterns import FuzzyDeduplicator, PatternExtractor, SevereShapes, is_severe, pattern_priority @@ -64,24 +64,23 @@ def __init__( self.mode = mode # Base config: an injected one wins over the level preset. - self.config = replace(config) if config is not None else get_preset_config(level) + base = config if config is not None else get_preset_config(level) - # Apply overrides BEFORE setting up logging + overrides: dict[str, Any] = {} if max_memory_gb is not None: - self.config.max_memory_gb = max_memory_gb + overrides["max_memory_gb"] = max_memory_gb if max_patterns is not None: - self.config.max_patterns = max_patterns - + overrides["max_patterns"] = max_patterns for key, value in kwargs.items(): # Fail fast on typos: silently dropping an unknown override means a # user's tuning quietly does nothing. - if not hasattr(self.config, key): + if not hasattr(base, key): raise ValueError(f"Unknown config option {key!r} (see BigDialConfig for valid fields)") - # Special handling for output_format to convert string to enum if key == "output_format" and isinstance(value, str): value = OutputFormat(value.lower()) - setattr(self.config, key, value) - validate_config(self.config) + overrides[key] = value + # replace() builds a new config, so overrides are validated and memory-clamped like any other. + self.config = replace(base, **overrides) # Setup logging based on config (after overrides applied) setup_logging( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index ab54ca7..8ec23a7 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2,6 +2,7 @@ Unit tests for configuration module """ +import psutil import pytest from logreducer import LogReducer @@ -68,13 +69,23 @@ def test_typed_masking_defaults_off(self): """Typed masking is opt-in; default behaviour stays unmasked.""" assert BigDialConfig().typed_masking is False - def test_post_init_memory_adjustment(self): - """Test that __post_init__ adjusts memory if too high""" - # This will depend on system memory, so we test behavior - config = BigDialConfig(max_memory_gb=999999.0) # Unrealistic high value + @pytest.mark.parametrize( + "build", + [ + pytest.param(lambda: BigDialConfig(max_memory_gb=999999.0), id="config"), + pytest.param(lambda: LogReducer(max_memory_gb=999999.0).config, id="reducer-argument"), + pytest.param( + lambda: LogReducer(config=BigDialConfig(), max_memory_gb=999999.0).config, id="injected-config-argument" + ), + ], + ) + def test_memory_above_70_percent_of_available_is_clamped(self, build): + # Available memory moves between the clamp and this line, and never exceeds the total. + assert build().max_memory_gb <= psutil.virtual_memory().total / 1024**3 * 0.7 - # Should be adjusted down based on available memory - assert config.max_memory_gb < 999999.0 + def test_reducer_override_is_validated(self): + with pytest.raises(ValueError, match="max_patterns"): + LogReducer(max_patterns=0) class TestGetPresetConfig: From 9a75743d8566aeb281e78553c9a9e3a696819c11 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:37:12 +1100 Subject: [PATCH 10/21] fix: window temporal timestamps by instant, not wall time TimestampParser matched an ISO-8601 stamp up to the seconds and dropped any fraction and offset, and the Apache pattern dropped its zone, so 01:00+01:00 and 00:00Z landed in different windows. ISO stamps are now read by fromisoformat with their fraction and offset, the Apache form takes its zone, and aware timestamps are shifted to UTC before windowing. Naive stamps are still read as UTC. --- src/logreducer/temporal.py | 43 +++++++++++++++++++++++++------------ tests/unit/test_temporal.py | 21 +++++++++++++++++- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/logreducer/temporal.py b/src/logreducer/temporal.py index fe185f2..6a785a9 100644 --- a/src/logreducer/temporal.py +++ b/src/logreducer/temporal.py @@ -19,11 +19,24 @@ class LogEntry: line_number: int = 0 +def _as_naive_utc(timestamp: datetime) -> datetime: + """Return ``timestamp`` shifted to UTC without its offset. A naive one is already read as UTC.""" + if timestamp.tzinfo is None: + return timestamp + try: + return timestamp.astimezone(UTC).replace(tzinfo=None) + except OverflowError: + # Shifting a stamp at the edge of the calendar leaves datetime's range. + return timestamp.replace(tzinfo=None) + + class TimestampParser: """Parse timestamps from various formats.""" + # The format None marks ISO-8601, which fromisoformat reads with its fraction and offset. PATTERNS = [ - (r"\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}", "%Y-%m-%d %H:%M:%S"), + (r"\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?", None), + (r"\d{1,2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}", "%d/%b/%Y:%H:%M:%S %z"), (r"\d{1,2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}", "%d/%b/%Y:%H:%M:%S"), (r"\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}", "%b %d %H:%M:%S"), ] @@ -32,23 +45,25 @@ class TimestampParser: LEVEL_PATTERN = re.compile(r"\b(DEBUG|INFO|WARN|ERROR|CRITICAL)\b", re.I) def parse_line(self, line: str, line_number: int = 0) -> LogEntry: - """Parse log line.""" + """Parse log line, normalising a timestamp with an offset to naive UTC.""" entry = LogEntry(raw_line=line.strip(), line_number=line_number) - # Extract timestamp for pattern, date_format in self.COMPILED_PATTERNS: match = pattern.search(line) - if match: - try: - timestamp_str = match.group() - if "%Y" not in date_format: - timestamp_str = f"{datetime.now().year} {timestamp_str}" - date_format = f"%Y {date_format}" - entry.timestamp = datetime.strptime(timestamp_str.replace("T", " "), date_format) - break - except (ValueError, TypeError): - # Timestamp parsing failed, try next pattern - pass + if not match: + continue + timestamp_str = match.group() + try: + if date_format is None: + timestamp = datetime.fromisoformat(timestamp_str.replace(",", ".")) + elif "%Y" not in date_format: + timestamp = datetime.strptime(f"{datetime.now().year} {timestamp_str}", f"%Y {date_format}") + else: + timestamp = datetime.strptime(timestamp_str, date_format) + except ValueError: + continue + entry.timestamp = _as_naive_utc(timestamp) + break # Extract level level_match = self.LEVEL_PATTERN.search(line) diff --git a/tests/unit/test_temporal.py b/tests/unit/test_temporal.py index 3aeaef0..1f57313 100644 --- a/tests/unit/test_temporal.py +++ b/tests/unit/test_temporal.py @@ -3,7 +3,7 @@ """ import time -from datetime import datetime +from datetime import UTC, datetime import pytest @@ -230,6 +230,25 @@ def test_window_is_independent_of_local_timezone(self, monkeypatch): assert windows[1] - windows[0] == 2 + @pytest.mark.parametrize( + "stamps", + [ + pytest.param(["2026-01-01T01:00:00+01:00", "2026-01-01T00:00:00Z"], id="iso-offset-and-z"), + pytest.param(["2026-01-01 05:30:00.250+0530", "2026-01-01 00:00:00,750"], id="compact-offset-and-naive"), + pytest.param(["01/Jan/2026:01:00:00 +0100", "01/Jan/2026:00:00:00 +0000"], id="apache-offset"), + ], + ) + def test_one_instant_lands_in_one_window_whatever_its_offset(self, stamps): + result = TemporalProcessor(60).process_temporal([f"{stamp} INFO heartbeat ok" for stamp in stamps]) + + midnight_utc_hour = int(datetime(2026, 1, 1, tzinfo=UTC).timestamp() // 3600) + assert [(p["count"], p["window"]) for p in result["temporal_patterns"]] == [(2, midnight_utc_hour)] + + def test_offset_at_the_end_of_time_does_not_crash(self): + result = TemporalProcessor().process_temporal(["9999-12-31T23:59:59-01:00 INFO edge"]) + + assert len(result["temporal_patterns"]) == 1 + @pytest.mark.parametrize("minutes", [0, -5]) def test_non_positive_window_rejected(self, minutes): with pytest.raises(ValueError, match="window_minutes"): From 54b01d5c5c37733058e09227525398021379ab24 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:38:44 +1100 Subject: [PATCH 11/21] fix: rank temporal and timeless patterns under one cap Temporal mode took up to max_patterns timed examples and then up to 100 timeless ones on top, ranked separately, so max_patterns=1 returned three lines and a low-ranked timeless pattern skipped the global selection. Both kinds are now ranked together and cut once at max_patterns. --- src/logreducer/core.py | 12 ++++-------- tests/unit/test_temporal.py | 9 +++++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/logreducer/core.py b/src/logreducer/core.py index ffea651..9f5aadb 100644 --- a/src/logreducer/core.py +++ b/src/logreducer/core.py @@ -418,14 +418,10 @@ def _process_temporal_mode(self, source: Source) -> list[str]: self.logger.info("Phase 2/2: Temporal processing") temporal_results = self.temporal_processor.process_temporal(lines) - # One example per (window, template), the most important first. - def rank(patterns: list[dict]) -> list[dict]: - return sorted(patterns, key=lambda p: pattern_priority(p["template"], p["count"]), reverse=True) - - result = [p["example"] for p in rank(temporal_results["temporal_patterns"])[: self.config.max_patterns]] - result.extend(p["example"] for p in rank(temporal_results["timeless_patterns"])[:100]) - - return result + # One example per (window, template) or timeless template, the most important first, under one cap. + candidates = temporal_results["temporal_patterns"] + temporal_results["timeless_patterns"] + ranked = sorted(candidates, key=lambda p: pattern_priority(p["template"], p["count"]), reverse=True) + return [p["example"] for p in ranked[: self.config.max_patterns]] def _process_hybrid_mode(self, source: Source) -> list[str]: """Process using combined approach.""" diff --git a/tests/unit/test_temporal.py b/tests/unit/test_temporal.py index 1f57313..9012900 100644 --- a/tests/unit/test_temporal.py +++ b/tests/unit/test_temporal.py @@ -307,3 +307,12 @@ def test_severe_shape_is_one_line_outside_the_cap(self): # One ERROR line whatever the cap, and the cap fills with the rest. assert sum("ERROR" in line for line in reduced) == 1 assert len(reduced) == 1 + 2 + + @pytest.mark.parametrize(("cap", "expected"), [(1, 1), (2, 2), (5, 4)]) + def test_timed_and_timeless_patterns_share_one_cap(self, cap, expected): + timed = [line for line in _timed_lines(3, 1) if "ERROR" not in line] + timeless = [f"worker {n} heartbeat ok" for n in range(50)] + [f"queue depth {n} items" for n in range(50)] + + reduced = LogReducer(mode="temporal", max_patterns=cap).reduce(timed + timeless) + + assert len(reduced) == expected From dd645463a959b7090863a8755c28bc14faeb2ce4 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:52:05 +1100 Subject: [PATCH 12/21] fix: keep anomaly and hybrid output under a small cap Anomaly mode gave anomalies max_patterns // 2 and normal context max_patterns // 4, and hybrid split max_patterns // 2 each way, so max_patterns=1 returned nothing in either mode. Anomalies now take half the cap rounded up and the other share is at least one line of what is left. Fewer than 10 unique lines, which the detector cannot score, now fill the cap instead of a quarter of it. At the default caps the budgets are unchanged. --- src/logreducer/anomaly.py | 5 ++++- src/logreducer/core.py | 26 ++++++++++++++++---------- tests/unit/test_core.py | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/logreducer/anomaly.py b/src/logreducer/anomaly.py index 2ec83b2..6a1a470 100644 --- a/src/logreducer/anomaly.py +++ b/src/logreducer/anomaly.py @@ -4,6 +4,9 @@ logger = logging.getLogger(__name__) +# Fewer lines than this are returned unscored, all of them normal. +MIN_SCORED_LINES = 10 + # Try optional imports try: from sklearn.ensemble import IsolationForest @@ -30,7 +33,7 @@ def detect_anomalies(self, lines: list[str]) -> tuple[list[str], list[str]]: Returns: (anomalous_lines, normal_lines) """ - if not self.enabled or len(lines) < 10: + if not self.enabled or len(lines) < MIN_SCORED_LINES: return [], lines # Vectorize diff --git a/src/logreducer/core.py b/src/logreducer/core.py index 9f5aadb..243a86f 100644 --- a/src/logreducer/core.py +++ b/src/logreducer/core.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any, Literal, overload -from .anomaly import AnomalyDetector +from .anomaly import MIN_SCORED_LINES, AnomalyDetector from .config import BigDialConfig, OutputFormat, ProcessingLevel, ProcessingMode, get_preset_config from .logging_config import get_logger, setup_logging from .memory import BoundedDeduplicator, MemoryMonitor @@ -390,13 +390,18 @@ def _process_anomaly_mode(self, source: Source) -> list[str]: self.logger.info("Reading, deduplicating and anomaly detection") anomalous, normal = self._detect_quiet_anomalies(self.anomaly_detector, source) - # Keep all anomalies (capped) + sample of normal - result = anomalous[: self.config.max_patterns // 2] + cap = self.config.max_patterns + if not anomalous and len(normal) < MIN_SCORED_LINES: + # Too few lines to score, so they fill the cap. + result = [] + normal_budget = cap + else: + # Half the cap (rounded up) for anomalies, then a quarter, at least one, for normal context. + result = anomalous[: (cap + 1) // 2] + normal_budget = min(cap - len(result), max(cap // 4, 1)) - # Add some normal lines for context. Seeded RNG so a re-run over the - # same input yields the same sample (consistent with the reproducibility - # guarantees elsewhere); there is no security requirement here. - normal_sample_size = min(len(normal), self.config.max_patterns // 4) + # Seeded so a re-run over the same input yields the same sample. + normal_sample_size = min(len(normal), normal_budget) if normal_sample_size > 0: result.extend(random.Random(0).sample(normal, normal_sample_size)) @@ -439,9 +444,10 @@ def _process_hybrid_mode(self, source: Source) -> list[str]: self.deduplicator = BoundedDeduplicator(self.config.dedup_cache_size, self.config.hash_algorithm) anomalous, _ = self._detect_quiet_anomalies(self.anomaly_detector, source) - # Combine, preferring anomalies - result = anomalous[: self.config.max_patterns // 2] - result.extend(pattern_lines[: self.config.max_patterns // 2]) + # Anomalies take half the cap (rounded up), patterns half, at least one, of what is left. + cap = self.config.max_patterns + result = anomalous[: (cap + 1) // 2] + result.extend(pattern_lines[: min(cap - len(result), max(cap // 2, 1))]) else: result = pattern_lines diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 6a4f064..6450286 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -637,6 +637,26 @@ def _anomaly_peak_bytes(count: int) -> int: tracemalloc.stop() +@pytest.mark.parametrize("mode", ["anomaly", "hybrid"]) +@pytest.mark.parametrize("cap", [1, 2, 3, 500]) +def test_a_small_cap_still_returns_quiet_lines(mode, cap): + lines = [f"INFO {_letters(n)} request {n} took {n % 7}ms" for n in range(60)] * 2 + + out = LogReducer(mode=mode, max_patterns=cap).reduce(lines) + + assert 1 <= len(out) <= cap + + +@pytest.mark.parametrize(("cap", "expected"), [(1, 1), (3, 3), (10, 5)]) +def test_too_few_lines_to_score_fill_the_anomaly_cap(cap, expected): + lines = [f"INFO {_letters(n)} started" for n in range(5)] + + out = LogReducer(mode="anomaly", max_patterns=cap).reduce(lines) + + assert len(out) == expected + assert set(out) <= set(lines) + + def test_anomaly_peak_memory_does_not_grow_with_the_input(): """The detector's input is sampled while the source streams, so 10x the lines costs no more memory.""" assert _anomaly_peak_bytes(200_000) < _anomaly_peak_bytes(20_000) * 1.5 From ea5b55fae7a159b01a2f6c17e6dc04dd879f6b3c Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:55:38 +1100 Subject: [PATCH 13/21] fix: match a severity level as a whole word is_severe matched CRITICAL, FATAL or ERROR anywhere in the upper-cased line, so INFO error_count=0 or a line saying no errors counted as severe, bypassed the min-occurrence filter and every output cap, and took a severe-shape slot. A level now has to be a whole word, in any case, so [error], level=FATAL and ERROR: still count. The quiet-lines golden was rebuilt with drain3 0.9.11 through tests/testdata/build_quiet_golden.py, whose severity check now matches the library's. It replays every corpus against the all-lines golden before writing. --- README.md | 2 +- docs/pypi.md | 2 +- src/logreducer/patterns.py | 7 ++++--- tests/testdata/build_quiet_golden.py | 6 +++--- .../template_mining_quiet_golden.json.gz | Bin 91751 -> 91944 bytes tests/unit/test_patterns.py | 18 +++++++++++++++++- 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 025199d..e979128 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It ships as a CLI and as a Python library with an IO-agnostic core. - A `logreducer` command that reduces a file, a SQL or ClickHouse query, or a Kafka topic, straight from the shell. - Pattern mode streams through a bounded dedup cache, fuzzy index and template store, so memory does not grow with the line count or the number of distinct lines. - Four modes: pattern (Drain3 template mining), anomaly (Isolation Forest), temporal (one example per template per time window) and hybrid. -- ERROR, FATAL and CRITICAL lines always come through, one per distinct shape and ahead of the reduced output, in every mode and however a file is read. A SQL or ClickHouse source samples in the database first, so only the lines it returns are seen. +- Lines naming ERROR, FATAL or CRITICAL as a whole word, in any case, always come through, one per distinct shape and ahead of the reduced output, in every mode and however a file is read. A SQL or ClickHouse source samples in the database first, so only the lines it returns are seen. - A core with no IO dependencies: anything re-iterable that yields `str` lines is a source, and anything with `write(lines)` is a sink. - Seeded SQL sampling and ClickHouse `SAMPLE` for tables too big to scan in full. - Adapters for SQL, ClickHouse and Kafka as extras, so you install only the one you use. diff --git a/docs/pypi.md b/docs/pypi.md index 93ade0f..b3ba13a 100644 --- a/docs/pypi.md +++ b/docs/pypi.md @@ -10,7 +10,7 @@ It ships as a CLI and as a Python library with an IO-agnostic core. - Pattern mode streams through a bounded dedup cache, fuzzy index and template store, so memory does not grow with the line count or the number of distinct lines. - A memory budget that decides how a file is read: whole, in chunks, or reservoir-sampled once it passes five times the budget. - Four modes: pattern (Drain3 template mining), anomaly (Isolation Forest), temporal (one example per template per time window) and hybrid. -- ERROR, FATAL and CRITICAL lines always come through, one per distinct shape and ahead of the reduced output, in every mode and however a file is read. A SQL or ClickHouse source samples in the database first, so only the lines it returns are seen. +- Lines naming ERROR, FATAL or CRITICAL as a whole word, in any case, always come through, one per distinct shape and ahead of the reduced output, in every mode and however a file is read. A SQL or ClickHouse source samples in the database first, so only the lines it returns are seen. - A core with no IO dependencies: anything re-iterable that yields `str` lines is a source, and anything with `write(lines)` is a sink. - Seeded SQL sampling and ClickHouse `SAMPLE` for tables too big to scan in full. - `reduce_to_target` to collect about N representative lines from a large source, holding roughly one batch in memory. diff --git a/src/logreducer/patterns.py b/src/logreducer/patterns.py index 2fb4973..c49b2d9 100644 --- a/src/logreducer/patterns.py +++ b/src/logreducer/patterns.py @@ -2,6 +2,7 @@ import heapq import logging +import re from collections import deque from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import dataclass, field @@ -96,13 +97,13 @@ def _typed_masking_instructions(timestamp: bool = False) -> tuple[MaskingInstruc return tuple(MaskingInstruction(f"{start}(?:{pattern}){end}", name) for pattern, name in shapes) -_ALWAYS_KEPT_LEVELS = ("CRITICAL", "FATAL", "ERROR") +# A level is a whole word in any case, so a field name like error_count does not count. +_SEVERE_LEVEL = re.compile(r"\b(?:CRITICAL|FATAL|ERROR)\b", re.IGNORECASE) def is_severe(text: str) -> bool: """True for a line or template that names a CRITICAL, FATAL or ERROR level.""" - upper = text.upper() - return any(level in upper for level in _ALWAYS_KEPT_LEVELS) + return _SEVERE_LEVEL.search(text) is not None class SevereShapes: diff --git a/tests/testdata/build_quiet_golden.py b/tests/testdata/build_quiet_golden.py index 04e9010..c5d792d 100644 --- a/tests/testdata/build_quiet_golden.py +++ b/tests/testdata/build_quiet_golden.py @@ -17,6 +17,7 @@ import gzip import hashlib import json +import re import sys from pathlib import Path @@ -30,12 +31,11 @@ # logreducer's BigDialConfig defaults for the fields the five configurations override. DEFAULTS = {"max_clusters": 10_000, "drain_similarity": 0.4, "typed_masking": False} -LEVELS = ("CRITICAL", "FATAL", "ERROR") +SEVERE_LEVEL = re.compile(r"\b(?:CRITICAL|FATAL|ERROR)\b", re.IGNORECASE) def is_severe(line: str) -> bool: - upper = line.upper() - return any(level in upper for level in LEVELS) + return SEVERE_LEVEL.search(line) is not None def read_lines(name: str, limit: int) -> list[str]: diff --git a/tests/testdata/template_mining_quiet_golden.json.gz b/tests/testdata/template_mining_quiet_golden.json.gz index d7f963567b64b67d719a4571cc24bd11661a48cc..61fbdd4f9856470fee65a8672bf08dae152b1593 100644 GIT binary patch delta 84952 zcmaI6Wl-EfyXGB2fZ*;L+}&LQgb+NqyTjnlpP&g60)qwymjri*K|*kMcOTptWOLr< z-Lt3m!&d#OtNT++wcLGQ*WNJFuTi8Q96&p|54ljH;G?cTMB?;ME8a{KVy0V%wv+89 z<(iJ>YFj`e1?5L4{a6;wp%^YPzLzIC1Ur-WpKD!{c1Ta0tX9d?a?zMS6QV^xCCRW{ zkZToD*xxMDhcTgFR1-k{qY{Z8~Q5rY)>`|T-3ryht zk|T`IC{g!&FMMM~d@l>x+1U(=z|(o(1Zeq4Ej}>Sc8~x590ohaJ^1mMesJu|?-j1G zgbknQ-P0HJI%S{}Rf%|6(H2Z!Mu4>sDIlE8+yN*(=BFP#C%2cfQRrgCRZOxO^5m$P z`WhR1V0*AluCRp!vyvXGi(}JZ@qp_K8d9uP+kx!bq1&y#fvKZ`J4P|UV$nMV&w*OS z&wa7=Pz8CT+LZd~X#(N?*eos^cxXIr;2VRbcuDu5?L96zi^DI4tK%3N+lOv%rndbB zTfHcg2u0KAl3_%TZI7>*=i-Xz7Lwui&udH^V+>MtQOLplh1aqGQR5n z?ynLIKR!i?e^}5StKf%wSwX+U<`SMnYfKeyjy{xoS&23&Ed&!TRMNt=t)S5`-GnDr z%>Y8&|I?~5mA@(dP_AutbKkHr+MjLma&;)y_ycItU5SH!gOkrh1ODeXt-E9s`NERT zsStrq!r-pHBR>Ydl^vv7$T2J z?ga@HYBKP~eGY9SiuRw*xG8b4_gAD+T}H3=z=2b)c?C(6S?1WzZ5_ncs^0wev2@l2 zg#~hHxnE^pZs77Lcuv(R+o#sHt(c*Gs@$tx;1+8k{Zx$D8cwNVJ0&Oo-Q_PeGNAu_ za%RIV0*mKb^Ko%m(|>K&M8+vXNb?u%@Q%6&T6lFL_1U$NRj;AF(QvwyN0o`jN%ZIU zN*n3`Fl>Lz?8|CReORZ)&P52cY`CW<16#0DlL_2dwM7AMUaBv*2Pl6w_ExQ|P(Giom|bzsTL_qsarNbw zjuuBirs~oOy}POM@xwoU-RGSlypRSZxA{KxWNfEqteRaJ@@BOiUAY9L&gu90lzU9I zLact=tldi=>o!7k|LdLrD*d@TTL-3;Y?Ka|*r*wGx0EaZVG5%g8}Gv|$20_?##)kG zoIEupgl~&!3iOgMNyekLI@|+&U9iRIiy*$~G*TcRzfBiwf5BB(K zKd<5K;V5OiV|8&`kSb&et#Mtm&E8<2?Cw z)WY_}m#!8&O#?u_K;u`oP1r_VhWKR(TQeC(t<}SV_Y>p2wVcQ!(Q6G2i-P++kMAxS z9uG8yZT#O1cPxt6N6dC@a-N*=f-|oIYN?oK7ratBJ0q?6t67i3jz`UY#{R9PC%@*5 z^iJsgTOny?!Nyg)UgnSWT7Oi}2E!GjkTJ^$zJFO3>uB!fRT(Gh!58ZaK;P{?1 zLmbZu<)Vow&ck9Uc6r-gmF)@Ef^R)9-w3CF_ivnX9C{riMsj<6rTW34*>cAJW$9o` z6_+wxhtfocJ&Tb|T5Z4$nmshc@=2(3QyH^=JlF$SN^FFaX#u9~`bI|@Ax^en#6Hk@ zfe%{sc)tcnJH$4{z-nxX2Tu-tAMzReGMBFuvX8Tu?0v)`F0)Ur*>O_k!34XwX1*1i47N7@*)bYV}F*e78=KvS=`dWZt^_b0}cl6x`M9n zk8d5xCODC5&v$&Af&wvMcV`P|u04FN(6Ko&=rG|x%1%wt`HlT1YK>FK2<^}}=f@a{ zDV3rxr4dFkomgERVdf6U(VClV{tdLGg}~ls;nr2?4!R+{w|_IvCd`bdJP5t?uyhK% z)ibpvrwY@nik$JZ>3jL}8iSL`@-2nT+f8P*R#>EY)S$|Q%AhOEw|zh81fy?u(^n)I zMU@ug&7F&A+a?rVZ42Fi_RKA8H?6hS`)@CasY$6yV)*-Hz%pg=Z<=2Rv$l-z17FTO z_7vfJe_U-l-WY5I;oS(8|GpX>k-Qx`+wf$GTDGdqYS*@xXUwoD{B|DpwPnDz_tr(Y zV;{5Y0+|6GWq-<=5%er~QLbgC*3fua`658w)9-O~id~W1CvUW6uOf>WF$)g}xY&Jp zy1mEdGRcyV>O%D@=|2h|?Spk60%>a5V(sK>pZ4x{d!2KLA~D&?7mD?3%!15`^>#R~ zLi3LFZT;y1vUP3>4zjhyIbqWb*Cy35BYCyV4-;~GX|P*02E zSc@Nhq_-85glDT9@s^t6l;cXuRt*R+CRO0Cz7}4(&gmWqC zVCa;8N;O8Al{$WYI8xJ5oiYWyx>8MnddFwJqrS(7BW3Bw>p8w7_ooN8e*xf2ct=+8 z2B72o!N0@qX!&{V)CnX)@$yiL!kWevImpYF?pl25;caeT6I>F6V6q;4*0wv6$+z*g z#sC9|#yeHKqM&R+a|n3cBntTP77a{MYow8)4PSe}Inr=f{JA%dOhIujz*G3(B0(Ke>o9Rc7r1vtD`=A>T7ri)&Y} zp0L}yK#~JRuC)V4ZfhLIWo8t-a%6FGuCDMk`h3{d`SJR1ZE?hT!hM4Eiy{drs!*!o zfR4Mn-_F|>W|aM7E8A*9>5$p%)}JK)KLUM48=MY!AB?|9HxXzLTs;3cJ`Rmc-N1J> zeDMr8a*`jy=O!=)-tRAxSq3>C8EP&04eETLRY5;sEo2JIeS6c)*Gfe1_p)y@V-n@n zlu;&WbMNbm>U@K(=)J$}o9*ELR#`ASzD4QXx9A4Jjh}wKtCW!f$5;0YYOXN~xr6nS z7UM1JCKk;e=_z$z#o&SHwJFcWE!bZ)&9{Z zi38(~Mw2PuyRN9lmD1;K4YQ(7jaQLWI~j(v3VtvBli^yWOVpS-YmcC`1>I`QRC4NP z75qCEDsGXWhpkInsdi5uFu_if-W!RrcDEIa6w@*Qj> zwL`|`PCS2MK<5R$V7!gk!J{~7NSxG2naUOgU0PI-6A=}?R7kEtFfkA8#OtE*+@^X( zs-^)N5GCJ|QKVzIJ4Ln)^5CP!%zTp=!TxjL96o$OKSa0KfZ`o#OLxYuTGK(e#yk5| zVp~p<$SDPq`T({p#aNB&z8z}ZFjT%g=FZ^gFljRd0;PV6xOX3co$O4XgpucF4GwPv zhA+>^+()?;Q2#F4PDiU;aAxcv20t8cwUUeAZWv%YKRly0e!m}g$b~5-hH6yX`9ILt zKkwOb*Do8vL3rxRym0#L&#|n(iE9sk1f{l7$4PxRf&P@leMihVO4vNL;gP7{B@D+U z3b!ZuK!!3<59tJ--ys*jB-H0aDP~s3$i7SY+YY7kOdXw9orcm0EtFY4-6ki%dwe&k zMeE1z6gx?F^{}sW46htlOY4p4QIfu)fOW8_0g<#4mRT|R1(!1L@`Sg-U^-#Q)6LoK z>YvkJD3?+N%g-RR&H1i8RGF+Mgbgw1yu`fqUQbH5ko|{`N=~I!13X@>zi`CPGMp<- z85%54cHC|4c?i%H_Mber2$pNlXA)`GGA=tHART}DEQhVP)3WTDF=AQf`jr+LuFWHr zfbYZZ^}>0~+?d*^S{Bsbk=UlBa`+1+xc;{Q=w#owO2Zlk4^v&Uoa_+qy3t{Kt z6xaL|oq~oUvFBYu@(4(iT6TGlu>(r+5DQ(|kLN>;EJl0$I*Y~d!i0)~o%gZ_j@6Ar9-WI^(lbj-o5(C-}FF=D#ca#g@vy_ zI*UVIfn9DphH=8Tgg*#Ji9d@ZAG*1m6lvT970+1=pH<3pGy7Rpq{LrADYu}T_1=Ri z?5_iMdzQ?l-!pXg5QyfpW9I_qznJ@@kn&5b%a70U*@O@58RDkJ+p(oRvLd`>i=&VUs1T zZH1*jI?NQdu=hQmg58?<3KWl3fxNIS)dVv(K4W`o`lVH;P^=oI?~SgW8742(o^HT2 z;jFA$7AVzPH7B;D!*pMpylW>(u{d*Unfpj0WUaYe_&jnk>a%1>2eHUF@Wn#!eZ-SZ z*1a^JaLaHFxM^IOBCcHR2z(|1OXCs&CdgW&W05b9(8@=->n@xr{6lRnuPQMM1U5a3~pxhS1np-4V>9{>$4 zl66qe09sCK7TEWBh7l;ostxB)q@n(+6Fx$ToLp==tpp+*Qod&3ghOjW zlLl+DytsL#8U*baV^Y0>1lxSO=;`#gO=n9TC%r;odl|%1-YYXS_E~Spd(fQTni;nke5!fRphVDm`blPyMr(YkZ5|sKPY?aB!#dM5Xn6UVLly&@ z-Zk!o(KU{i+I7SbA0_lBuW~#vv>pD%UErxhXyn0sI8&u&7&~WV3=Gluvx zHxq)imGJcEFf~nKR$sE90b(qa;bt_y@eLW4ym z13WH-mB6>u$f(IlAe-$*`W*x2+-89|OjqnPi{PE9+AR75>W!7CYGXQcFya|~8Bba7 zGY69(Ne-l|$#Q0|%)?`%{%cj%JN)<5AdXXWCIQU^!WqX~`6-tv`?5x5&ZqeIu`cMs4k-keK??57cy8H*NIn;s@DrMOeJ@YkP|HKXYGVgALpjhncl zaYOi8@xpZPc(K|mNdy5=Q#Tot5kI~?7d$xl_#FJ>aaKvyWI+jdSioAr*54FCYG(C5 zNE>z2jhqbgbW_;4^5&*+FKU%x7=)s+DVF|F!e|B9}MJ zFD3?^6B+zGMvGob9v(c1h;YSt25P9cChBTqCGimUoLyjbly}_cbz0--z20S8=qrm(pd+wQ%RPt1FK8cXSGi5Y8BJuREk^E@ zQ}^(H-C?QPUPV@>Ja0ZAM&dPJ1P}$EHb+>NiC-9lX$Z-%Pt{4kCYPYL)?H^|`kyL* zl?dMf#iiJXuU@hEy^_wN`@PWNv1x8&mH0rKp2Bm@%d5=IlK6v-+Df)!XPx(5V~*v6 zxy?^nf+Ivo4N3!F6!@cn3)&HUF}1eV+{2VpwRU_qFqKduTE1dq1|-=YNv{Hru@cW- z8MQsfl+J1~QyOqi8ZC9&JWTI`hU9~P$Iq)U)cUL=d_vdIlTWhUiq97ZT`d1(A_C|NS_k6w;`B z>C-oJo!@rO)D*F5$DglQ>eN1;Z8xBLN8<4JWAvx7FnLcPxl?w7`L`@!-`$ItDQ=t!>dkRMAVG>+WloE9WXylU z@j;Rv)M}!^YLUps$QCA&H7K5Y|B`L&l=a8CtzS>b>#q63XLQp%1!5tAKo$d!TUz?5Ta>D9EQM5kS}mC-RR$Ok(cyvA$25C& z5Vz?ld*R%{EdG~Rf2mhqzEnYTI9tV^*n9ic#?(6f{gjBkA38X`VZ zerYe*UWl235_lR2NNs6Qs$p{UVdRSbqSW}d6?XwKg_eHHYmK;O@4RTU>D@m`p! zw4Q8d!ODY{IJw#Ew7jaO;X){+R&G$Z*A7iH=R2?v~F^ot#q=z!M0lYW5No z&qj+gvBrrWapQ@Sjs*)}_l3893p$AFoJy~UxFo8G)~KZ}l!IMKxd6i#^TAXRUW0xD zcdNIJwGTWBxVb2?zl{H?nSLtv7?-QsIry$RmUD1{``L?1jri@KT2IFtq2?&qY3H>y;Dfj|@It9mG!1y6sB>yQnHFV(Lrvt>qK<=r~tE8a-Cdt$+; z)%?L@ijJ;iL)IFp_P|lce99tjZ@kYt`+V1+;!fF9k-JH3KV(Wb7Ak7p>3Ob`MbcLK zHs4F}mnugIZaBEhlc>ROgMj_<&T@N&&ITnw5g zi}heVUQZi#-T0u=cY)X7(Ju83TxJk`4{yW2n>T(2yXJOU1KUx-h-LyLd!f3sePr=i z#(H)fs!>{{dNE=IoIb2x5eUU7-#(#QE`wUg2oM(04iY$~z?|OqSc%tXv9`I%XTk$9 z)a#!1Caz%ow$(UeRg4Jy$s0x{r5GQP_8*>{^s&MTRNNlLp8Q+Zu>}!7Ltc#(#(Zjl z|JLDP`h+O_8PG<#zUx+vs$R1G<>HF!=xT0gR5w&;{M`W4LB#>3Uj6lsDhJp-=p4m8 zV4&5Fc{C0GB>q_@gps|!&>v4(PL$oTyvzI+abloa4A(;|bg}lc8vC8P%SPVU&s1S3qzB2eYU?YP15E?O$g$_C99U znw+7+9zO=TR`3Tq;@%?cos+NNY!hhk`NLR6%20-GrAa1a!N)E6h{ZH^F~@yF^2^}$ zl}Ws9Sn1dR<6_%E#avk)w|O7Oq0bHO-(E;-_x%2u;ceOpOb{GjwBukG%-lUF%IS5g&lZzaGd;QSGM}L^{cq{yc~V* zFEFk@O^9r=Xpd39nQ8Jw?c-=2+|~-}&?yr0U#ppkHOY>D`nEuIHQ9-qGnucEiS zG#7=|zjQJ`-ne!B0Jk{Dx&P!~+rQPtJGI@SJy;buB?q5-xN;9Q`skdBHqv(V>u7qG zGiFsX3kPawr+ir8O^^x!V>VaT9z4V`9YDy{N{oGFPmFwJqg}+`vHS0!<{tTE5rE|D zfgHhXnR&MPu2HXJY{rVrg}o7$G=42Tq#zA@5!6auB}_K0CS2NER5bJH*%%My@Ok(3 zJO#Ahh(6bHXw?>=M4T$sGfm+^Iq#`JY)KPq#->2g~B?QpU7$}B>n;hVl z6OS`G6JkN?Mt_z23`BH!?iC3~f~)*m`cYd(&}kan%x&rGJY=FQ4+f9PP0Pe)Mja8D zgC%6W+znf_EXi+at7~)onl?hGQM2S(quJSFzQ%R?ixpZw5fIkt{K8io1Pn9#JcKeH zT?I*7n(X0TQXQllo%zN31c%f9Cxx41 zE~vODnoK+0Tiz@&#Vf|6o;b#L?zq@`6c6!9Ui+lxl1O(r4P|9(_mVG~N&|&+eE(C& z@yIrx2;I;GDw9~6h?>z3xeEzW%_F=28r1(}*v8>J6jQ5@Xb+@AK1-X>P+ayk+$-~t~- z&)lM1DfGgrT}n8uv4yo$*NC01KPJP({hGQ|BuoXX{rm_sm_IPcvt}iI3USuS>MizI z1q@Djmoz2Y2yBI38jAS<)JgIJK4RuNu7pv*sI$Ft+op`8WrwZp(n0C5hfNK$&Y44< zQbmp{Xw!jjtX(ywRZ2gSY^5wX!+Di+BhJG;`wuK0JcV<=73{Xy6H7VYVYtVJ`Jz~x9-ISM zB^{6F-;Hhxu|YWcTfX7sUsU2Ceq|F&n1xQKx~Y1rqzAuz_X;Ei#NK1rtTC*D9|V=VEUL1KWeqh>H4 z8njL@zE1pRF)m}QEg~`=4#FImF?*}Eem8T!1QvQZg^mHf=NGVhrHkX8e})T5nLPR6 z#*Ry#R?tMY>givhDUl98H+WdU{k&~aoXx+?e*pjOW8{!Cb}V=aR%6(B;B4IonYe!U z$oDqGtBhy(Cr+GGZTonU3(z{UJuo|NlAMxV*4P>?ZRN4aX7bg5D#!EOBzsO>z1^oe z-;|`}w_eSBuFEug#mb|m31Q5V;CpfOC9#-~koqOZBPuE>UVU_{>2}d#Oc+;?wd#sw zQvL1)%;{Ga@&707nMi8ehGk%2QSSNKnCFfvw8xmYN3;mjRa%V$Wo}z4?4cGoQufdA zyY1`ddQ4RlNOt@E(B&N4rH!9fa**KK@d*=PT&l|xf{omjhb4DP5pF&b+%ITI&OWeq zX~r|Z{-+}-J18>M2v>%M5atF?H1MlU%;5Qt45n*m9-N?3w%PD^XCzRVoPyKRjC=8; z$Xjdgz$fHopcMafg>xPw#o3iVOm6CFd$du>b(So6p&(;!42V>^-n@?aCp&wGQ%pL9 zw2!{%y~$=jJKdX4z!=-uZJGT-uAz-?J;M$$%4gKH83 zUX7=)uQ_u%w)Y!pmG{K6h&6*vttF$+cK7@ZtibknKvSh>>3+v+le6<-tKzF6H*F?X z$55RPsPVY)zw-MHVl;|;R*T_nD@*6TO~TGJlipV0kufg**+kS*^>IcRktZ~M!J)eN za>P--q)#p=(`A1kVGO!Hg?9I%%Cw8`a!&+O1kM~8mHs9E760V&r9MREyGga4mpJ;r z<>apd$}^_P60y1?`bB;4vdyypP>-DLMV7_a42KS^>AURvr|F*1)D}$u=qp2bvBb!P%hX zwu~MJfG-k3vV7&bRrm&p#BpGPH-r!i2-f9gFB8o$zLGSI-=tio;LFh4V1nK<8uxp{ zs3eYdsgREQRS#Fb#he%C|I>I9BmOB+?7j?~fp1L;1v(!2iYw#SvYzjD%PcdVQt8&> z56~83;`-k<8&=RPrF3iGqt!6Ho3UJnp%XqOUJqFe7|-M+w*Hb4pj<9+azrWtJ|DBG z|3e}K?K8alhw({uK7j_Jy3VbgNX+1GeWUpnms!;>mCLAN7}M7Ds-cx^DM){?Q2O`_%h| z*M91?KfE^Uzqzkg^f$xcDE=%SJIB_6=hAOdli9e)pV3A+T5dd1{*wky{mH0A@~HOd z%k=-nCgSf;CB`PxdaBqRwJyv3qJyV@-vO<{Ys6>O9L(3`;`iNIcH&{NlX$59y~fpO z!w>2%Yk5K!Zf~;XbXc{fTIcIQtAsLtuZXfpCg&H!}_=VFKKI&jZvtjzRau zFg}YRd?(d3fLZ>5dXt9)6~A`a=TKU@WcOjj{9xl zuAjv_jjgCDyi{=*JuSmlyugO^DV|Dn7NXqd^QPm`<# zEzGx1?QRB+P7FQ71moVLQmL%5cq8B*3iwgwP1*R}%uCnoX3-|#-s7#lm zv&5Dlq6p0c1JlvqMedBD{!)d~#3b8L9I0<(19d(W6hG@z zs?%Y7)CqC^P4+~8E8!krNn;l0m7H|s;BSkiiUnu+swFNRjVoXXOvcH?*q50^d)u z?F5rbW^ytl5qDLU#zL&?qs`du&KzP}D8)gdC~`Ol=cSj8Os>ysK=#Yo=FY+;c-zn? z=?S%l_5p)Z*y6Z52(lXITI3OUxp{+fdr_@+MLANVR;%5T3~>Kk!|Wq&H&=b2E^04z zy1B;|9`RYapL_WkZ~ngd0{hFdx9>ytg42yiGiXC<>1D@aTw$vnv<^iXdUp0+C?C?* z8=+~ug3@Grxp@v|BeV{KCs^?vZb5o~CAWf2j`D6=2~)CO=G6LZT~(a3 zJm*)GUR*tH#f1)HB5^5S_zii1WT1mp*@yt_a!*}Vwcx{n+=oFLk-IrGZ&!e z;u7~u3!kFe`XhyC+}a9_-JaIni>6Q<6i`{mJS?zM2` zB@xXB@3a2OY{{Hh{|ZxnS_$|Ked~s<5MILNlYSGgZc*jQd|7mBe}1mD)Zc5ikM?FK zS)ZTBQzJ8C;?!F?PApBO8$F_8eUclYy1Eg zdG&br*Wg0gL`md%mB=lsKj5+vSCmDkAJq1!vrC&3JGb=?N-pKr2Y8x~eSW8M0x2u& zD~iX%RmfRwX!l>#dYW; zMadweCJ1!`$id5FikTYu_QL8Xgfew(gHda5(0as?%-SAfHCMDCe_HFMRbCSCY%f^d ztrmQ$(voA-`p@yvobget2JBCLZSi6iS4`VsrZxdJ@@{KbBza@%KV-K zjt5y9lnbx*AMGDtu*-7erbfbVD)JE8YQX<#s`T`6P44AZ6!``V)f^zFlHWzwF#9_8544TpUujcR7w>aegw9|&J< zSI+NZaLHBC6J5PF_wT2Vs!J`4>7|HgyW+%?VJFvz2g_A=eQNs;T$W6pr8mbiDG9+j zkok`3MlHlAM{qSKBi54{Gl!Snql~)2^Za!fc!!gnyeAi3to~?XPWf>4-f73jvCwNy z%HV$H0bxxH%+5tZ-ec~z{R@OD|CZWQ#9ovTMdvpvxML!-sQgfxXWU#5VRlj`1aU!Kd<|hV7znQIp^DUW*t$NK=2L zj*i(|p72A-O;o+la?!{mUx;zM&#q7N+-Xc1gst_tF6jpaFBwZ$3*V0XWU6Xn>2JUE zLE3pIT|2WBdeWY;!$sJ_G%0vWwK%xK87z>HU%b8Z{-iJdR3EP&Nuk305iP%*y?M`M z{g$S;5#^e=*xAo9VGX&}0DQ(Rf{+Vl<#~tT1OCLrZ#?(&{V~|YNfVx4_p5aO*3{%- zgoNw*S6tg29+lKkW5%T#%GT7m)$W)p7vSe17@WK!EbY3fdMd8RM=^nno^oFSTAgQ& z$804J{DvZ9Rxw1&?|&eZ2~c?7Lvg?>Jah>0U<6)5HUQ#kfOd4UF=vdwRuDKXfk*gz z-_s{X*eP2PT}(hkZ`@#^xb=+?GlEw(ScaDf<9FW_kr&6zFZ<-oD6l2(J25QfYv0L? zK*@`>ia>Q*0_7u6C-uj4o1u!HrjC%%-G7<+%Se;9r%@pgkC9l4&Tb7Re6n`e(}r+F z1q|gS1gM+74)Kys6NB#IUHKb#E7y;kkD^DIwsYP=Uo1E)ie3ziiT_#u6^x1f8_UpD zzR@Pt$bD>m@A>;+@bmMrXUa+zR6OWG$S#~$_C=5iBD;wicz)F)II!MYIF$?!9JmAibz{2zZwE`c}^GS%gXeWu_Hhnhv-yJ6lbzWHZg zUaBjxA4o{HhAVxLsbZ$FY^Wq~DH=ONViJ3YQ+>Yratn5=1nlE>Ev3j_TUq{@YOL5@ z$0Nk!AN%{Xy2tm`RALBrMbgb>e6BVoKl&wREs!qG{9n;zqZxP^my-Ceh)B4|aQy|) zPCl3Y=(C9EQyumGFrq^WL!VNs#r=iy)`P%Pk#;4Ww%yOYn2S|q#>SN7(Gs4QhD)Ki zm&@i|1(6J>e*?wv>d&QVHtYr(_)b`UDN$1nO9jjR`uvgL)jr3ozFC!@Bk%>vkjE{i zq++DJ^`bu@DZl5NkpcZll@M)ptE84%4Sp^JL~N-XaF_rSrhB3_-TpeBCHHl92PwEw zfDyqFyehzf44ud9{0+~?6+}WEGimb=4!+qu0F#Mvy*~L|t1PC8gz%i7CH)$q5u7bc zf^Z6Mk`)DNVY{;$0fNCdLGI_IF7cGtYz+j|_jx(V<8#y@;j6veVLoGJSClM6k$a6? z$031ygC>{?m-@(1VR9+zcJf$u9~7Yldw?X8GCF;}_0+F*ZlM_Z2d_tpVN9HTHjpkf$j7TP5gt^v-5MAK zW!y+wZ7`|6LkWd1M0Hv3>d9bpMj^IlwgJ93;S?ATYSDW6l+1MqpF0lYoLqH$`35Ds z7tNK%s3kwxG_a`!-kPU)O@^IUy}T!LHeJ*xkIwygjctee^i$-opKzKA4Tdl)S6yjC z3RP#}vYIhqJSSxmf20WpGGju-l_S>Z{#L{{;ovKyd8HzhFk31 z+y5Nqc%NUa+G-w!M>693SDhs58CARkM4?}j*C)9e&PG+CPW)NWR?hWU_Sf!moUgvr zBTIc;j#6@!Bxllsud>)kO(?10D7QXsj%;81A`g8HLgaIr?vD?|-*i88 zwpY0{{jMvFw{fI4?yFeAUR@Ry+EcpN8eD?P_O&5ig;O=TbPU?eZ3RD{T)8{VrA$NJ zA_>X_T$UIfmnTKSP1g)E*nzsFX9?2+eD!Cw=*Tx56zloyN7PFQyAKoST&hx7HYouq zA}I~Hk_9YW4*uL^O@znCR%f|B_af=~Tt(1};Fd^t90jo}-o}RYxtGui2a!O(_vkyx z2#XdSW?X@D*IhQoLy}k5D8(H_QmkejoZI6wQ>*=M!TecI=&_4K8-VYsT3M=Ky@diP zm!`(3>Mh^6C{oX9`OGf=_Q4~b(Up;W(RZ0%(Hh$2OKh{WPl%)PHtnG@2*&&2I05*75YAS(stjce49$>mZyP%+!d8}EvwfU@j~)Dl(8}=NB`-`q zy!!+j?HrW~-pqIA?_G5D)4(gRNNKjhp$K1*{Tt(r?!43@`vH5-!(f6p1NCpG&Fgwc z5~p@L#}t3TPitQBM*x9`)gjE9W)>e@pIdofg2=+L=c@4 zp_R#egMOBdmIeI=0VgM_EA1=7H#mls=@U(NhVv7aEGnp2>iVzH-mH6Hz0rtxJzl7$ z!}>kkoA753kuuHug82fcMa{48Bd+S_8`$NA-gvPoPGOk8afMnOzWQPne2iu6_Bc++ zKQg#>YbQM0fAR1GKwb!V=fk2SkK{r=&FqU#Y(+YFcH`9>#EX`$yvT5Y-sa8DZ@fVh zA;bUGKC!9UD)_^4EF?<^LuHx2JQH2$Gk)DuN%0DE!KlMQnk$+@$8Y!f5xcU42l*78 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

guh8AjV)WO}`90 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%0AQp` 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 delta 84728 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 Date: Sun, 1 Nov 2026 17:56:52 +1100 Subject: [PATCH 14/21] fix: start reduce_to_target on a 1,000-row probe batch With adaptive sizing on, the first batch was a fixed 10,000 rows whatever their size, and the memory watchdog only ran once that batch had been fetched and reduced. With 100 KB rows that first pull is about 1 GB before anything is measured. The first batch is now the 1,000-row floor, and every later one is sized to the byte budget from the rows already seen. --- src/logreducer/target.py | 9 +++++---- tests/unit/test_target.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/logreducer/target.py b/src/logreducer/target.py index cb9fb7b..6778a33 100644 --- a/src/logreducer/target.py +++ b/src/logreducer/target.py @@ -5,8 +5,9 @@ representative lines until it has the number the caller asked for - or the source runs dry, a fetch cap is hit, or the representatives stop growing. -Peak memory is bounded to roughly one batch plus the accumulator: batches are -sized to a byte budget (from the observed average row size), and a psutil +Peak memory is bounded to roughly one batch plus the accumulator: the first +batch is a 1,000-row probe, later ones are sized to a byte budget from the +observed average row size, and a psutil watchdog shrinks the batch and, as a hard backstop, stops the loop under pressure. This is the memory-safe path for "give me X lines from a huge table" without a full scan. @@ -28,7 +29,7 @@ from .core import LogReducer from .sources import Source -_DEFAULT_BATCH_ROWS = 10_000 +# Also the first batch when sizing adapts, as nothing is known about row sizes yet. _MIN_BATCH_ROWS = 1_000 @@ -105,7 +106,7 @@ def reduce_to_target( rng = random.Random(seed) seen: dict[str, None] = {} - batch = batch_rows if batch_rows is not None else _DEFAULT_BATCH_ROWS + batch = batch_rows if batch_rows is not None else _MIN_BATCH_ROWS fetches = 0 plateau = 0 reason = "max_fetches" diff --git a/tests/unit/test_target.py b/tests/unit/test_target.py index 456763e..8ba490a 100644 --- a/tests/unit/test_target.py +++ b/tests/unit/test_target.py @@ -118,6 +118,27 @@ def test_invalid_loop_params_raise(kwargs, match): reduce_to_target(EmptySource(), _reducer(), target_rows=5, **kwargs) +class WideRowSource(GrowingSource): + """Rows of about 100 KB, recording every batch size asked for.""" + + def __init__(self) -> None: + super().__init__() + self.requested: list[int] = [] + + def sample_batch(self, n: int) -> list[str]: + self.requested.append(n) + return [f"{line} {'x' * 100_000}" for line in super().sample_batch(n)] + + +@pytest.mark.parametrize(("batch_rows", "first"), [(None, 1_000), (123, 123)]) +def test_first_batch_is_a_small_probe_until_row_sizes_are_known(batch_rows, first): + source = WideRowSource() + + reduce_to_target(source, _reducer(), target_rows=10_000, max_fetches=2, batch_rows=batch_rows, plateau_rounds=5) + + assert source.requested[0] == first + + def test_fixed_batch_rows_disables_adaptive_sizing(): source = ManyTemplateSource(n_templates=40) result = reduce_to_target( From f1bf8bf1926cb506694b1f26f95a7ec8ff29140b Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 17:59:54 +1100 Subject: [PATCH 15/21] fix: key a seeded SQL sample on a hash of the row A seeded sample was setseed then random() < fraction on PostgreSQL and rand(seed) < fraction on MySQL, so each row's draw depended on the order the engine scanned it in. A table holding the same rows in reverse order returned a different sample on both engines, which breaks the same-rows-every-pass promise the multi-pass modes rely on. A seeded sample now keeps a row when the first 32 bits of md5(row text + seed) fall under fraction * 2^32: the whole row as text on PostgreSQL, and the query's columns, read once with a LIMIT 0 run, on MySQL. MySQL's from_table predicate hashes its column the same way. build_sample_sql takes the quoted columns as a new optional argument and no longer returns a PostgreSQL setseed statement. --- src/logreducer/sampling.py | 87 +++++++++++++++++--------- src/logreducer/sql.py | 27 ++++++-- tests/integration/test_sql_sampling.py | 25 ++++++++ tests/unit/test_sampling.py | 21 ++++--- 4 files changed, 118 insertions(+), 42 deletions(-) diff --git a/src/logreducer/sampling.py b/src/logreducer/sampling.py index 0c6e8aa..f4b13d9 100644 --- a/src/logreducer/sampling.py +++ b/src/logreducer/sampling.py @@ -2,8 +2,8 @@ Three concerns, all pure and server-free so they unit-test without a database: -* ``build_sample_sql`` - wrap an arbitrary user query in a deterministic, - per-dialect random predicate so a fraction of rows is returned reproducibly +* ``build_sample_sql`` - wrap an arbitrary user query in a per-dialect predicate + (a seeded row hash, or ``random()`` unseeded) so a fraction of rows is returned (needed because ``TABLESAMPLE`` only applies to a base table, not the arbitrary ``SELECT`` a Source is given). Mirrors ibis: raise when a seed cannot be honoured deterministically rather than fake reproducibility. @@ -17,7 +17,7 @@ import math import random -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from itertools import islice @@ -88,27 +88,40 @@ def wrap_subquery(query: str) -> str: return query.rstrip() + "\n" -def _pg_sample(query: str, fraction: float, seed: int | None) -> tuple[str | None, str]: - # setseed makes random() deterministic for the rest of the connection; it - # takes a value in [-1, 1]. Each pass opens a fresh connection and re-seeds, - # so the sample is identical across passes (re-iterable). Caveat: strict - # identity also depends on PostgreSQL visiting rows in the same order - - # synchronized_seqscans on a busy table can rotate the scan start; ORDER BY - # in the query if bit-exact re-reads matter. - setup = None - if seed is not None: - s = ((seed % 2_000_000) / 1_000_000.0) - 1.0 - setup = f"SELECT setseed({s!r})" - return setup, f"SELECT * FROM ({wrap_subquery(query)}) AS _lr_sample WHERE random() < {float(fraction)!r}" +# A seeded sample keeps a row when the first 32 bits of md5(row text + seed) fall under +# fraction * 2**32, so the same row gets the same verdict however the engine scans. +_HASH_SPACE = 2**32 + + +def _hash_threshold(fraction: float) -> int: + return int(float(fraction) * _HASH_SPACE) + +def _pg_sample( + query: str, fraction: float, seed: int | None, columns: Sequence[str] | None = None +) -> tuple[str | None, str]: + wrapped = f"SELECT * FROM ({wrap_subquery(query)}) AS _lr_sample" + if seed is None: + return None, f"{wrapped} WHERE random() < {float(fraction)!r}" + row_hash = f"('x' || lpad(substr(md5(_lr_sample::text || '{int(seed)}'), 1, 8), 16, '0'))::bit(64)::bigint" + return None, f"{wrapped} WHERE {row_hash} < {_hash_threshold(fraction)}" -def _mysql_sample(query: str, fraction: float, seed: int | None) -> tuple[str | None, str]: - # RAND(seed) with a constant seed is a repeatable per-row sequence. - rand = f"rand({int(seed)})" if seed is not None else "rand()" - return None, f"SELECT * FROM ({wrap_subquery(query)}) AS _lr_sample WHERE {rand} < {float(fraction)!r}" +def _mysql_sample( + query: str, fraction: float, seed: int | None, columns: Sequence[str] | None = None +) -> tuple[str | None, str]: + wrapped = f"SELECT * FROM ({wrap_subquery(query)}) AS _lr_sample" + if seed is None: + return None, f"{wrapped} WHERE rand() < {float(fraction)!r}" + if not columns: + raise ValueError("a seeded MySQL sample needs the query's quoted column names") + row_text = f"CONCAT_WS(CHAR(31), {', '.join(columns)}, '{int(seed)}')" + return None, f"{wrapped} WHERE CONV(SUBSTRING(MD5({row_text}), 1, 8), 16, 10) < {_hash_threshold(fraction)}" -def _sqlite_sample(query: str, fraction: float, seed: int | None) -> tuple[str | None, str]: + +def _sqlite_sample( + query: str, fraction: float, seed: int | None, columns: Sequence[str] | None = None +) -> tuple[str | None, str]: if seed is not None: raise SamplingNotSupported( "sqlite has no seedable RNG, so seeded (reproducible) sampling is unsupported; " @@ -133,20 +146,34 @@ def _check_fraction(fraction: float) -> None: raise ValueError(f"sample fraction must be in (0, 1], got {fraction!r}") -def build_sample_sql(dialect: str, query: str, fraction: float, seed: int | None = None) -> tuple[str | None, str]: - """Wrap ``query`` so it returns ~``fraction`` of its rows, deterministically. +def build_sample_sql( + dialect: str, query: str, fraction: float, seed: int | None = None, columns: Sequence[str] | None = None +) -> tuple[str | None, str]: + """Wrap ``query`` so it returns ~``fraction`` of its rows. - Returns ``(setup_sql, sampled_sql)`` where ``setup_sql`` is an optional - per-connection statement to run first (PostgreSQL ``setseed``), or None. + With a seed, a row is kept by a hash of its content and the seed, so the same + rows come back whatever order the engine reads them in. - Raises ``ValueError`` for a bad fraction and ``SamplingNotSupported`` for a - dialect with no sampler or a seed the dialect cannot honour. + Returns ``(setup_sql, sampled_sql)`` where ``setup_sql`` is an optional + per-connection statement to run first, or None. + + Args: + dialect: SQLAlchemy dialect name. + query: The SELECT to sample. + fraction: Share of rows to keep, in (0, 1]. + seed: Seed for a reproducible sample. + columns: The query's column names, quoted. A seeded MySQL sample needs + them to hash the row. + + Raises: + ValueError: For a bad fraction, or a seeded MySQL sample without ``columns``. + SamplingNotSupported: For a dialect with no sampler or a seed it cannot honour. """ _check_fraction(fraction) builder = DIALECT_SAMPLERS.get(dialect) if builder is None: raise SamplingNotSupported(f"no SQL sampler for dialect {dialect!r} (supported: {sorted(DIALECT_SAMPLERS)})") - return builder(query, fraction, seed) + return builder(query, fraction, seed, columns) def build_sample_batch_sql(dialect: str, query: str, n: int) -> str: @@ -197,7 +224,11 @@ def build_table_sample_query( # No TABLESAMPLE on these - a predicate on the table (full scan, but native). if dialect in ("mysql", "mariadb"): - pred = f"rand({int(seed)}) < {float(fraction)!r}" if seed is not None else f"rand() < {float(fraction)!r}" + if seed is None: + pred = f"rand() < {float(fraction)!r}" + else: + row_hash = f"CONV(SUBSTRING(MD5(CONCAT_WS(CHAR(31), {column}, '{int(seed)}')), 1, 8), 16, 10)" + pred = f"{row_hash} < {_hash_threshold(fraction)}" elif dialect == "sqlite": if seed is not None: raise SamplingNotSupported("sqlite has no seedable RNG; seeded table sampling is unsupported") diff --git a/src/logreducer/sql.py b/src/logreducer/sql.py index 2b8e232..c62cec0 100644 --- a/src/logreducer/sql.py +++ b/src/logreducer/sql.py @@ -71,8 +71,9 @@ def __init__( yield_per: Server-side cursor batch size (rows fetched per round trip). Larger trades memory for fewer round trips. sample: Optional fraction in (0, 1] - return only that fraction of - rows via a per-dialect random predicate. Pass ``sample_seed`` - for a deterministic, re-iterable sample (required for the + rows. Pass ``sample_seed`` for a re-iterable sample that keeps a + row by a hash of its content, so every pass returns the same rows + whatever order the engine reads them in (required for the reducer's multi-pass modes; PostgreSQL/MySQL only). Without a seed the sample is best-effort and not reproducible across passes. sample_seed: Seed for a reproducible sample. Raises @@ -108,9 +109,25 @@ def __init__( if sample is not None: from .sampling import build_sample_sql - self._sample_setup, self._sample_sql = build_sample_sql( - self._engine.dialect.name, query, sample, sample_seed - ) + dialect = self._engine.dialect.name + columns = None + if sample_seed is not None and dialect in ("mysql", "mariadb"): + columns = self._quoted_columns(query, params) + self._sample_setup, self._sample_sql = build_sample_sql(dialect, query, sample, sample_seed, columns) + + def _quoted_columns(self, query: str, params: dict[str, Any] | None) -> list[str]: + """Return the quoted column names of ``query``, read with a zero-row run of it.""" + from sqlalchemy import text + + from .sampling import wrap_subquery + + # Risk accepted: the query is the caller's own SELECT, which this source exists to run. + # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text + probe = text(f"SELECT * FROM ({wrap_subquery(query)}) AS _lr_columns LIMIT 0") + with self._engine.connect() as conn: + names = list(conn.execute(probe, params or {}).keys()) + quote = self._engine.dialect.identifier_preparer.quote + return [f"_lr_sample.{quote(name)}" for name in names] @classmethod def from_table( diff --git a/tests/integration/test_sql_sampling.py b/tests/integration/test_sql_sampling.py index 0b67367..3e5afbc 100644 --- a/tests/integration/test_sql_sampling.py +++ b/tests/integration/test_sql_sampling.py @@ -16,6 +16,31 @@ pytestmark = pytest.mark.integration +@pytest.fixture(params=["pg_logs_engine", "mysql_logs_engine"]) +def reversed_logs(request): + """The seeded ``logs`` table and a copy whose rows are stored in the reverse order.""" + from sqlalchemy import text + + engine = request.getfixturevalue(request.param) + with engine.begin() as conn: + conn.execute(text("CREATE TABLE logs_reversed AS SELECT * FROM logs ORDER BY id DESC")) + try: + yield engine + finally: + with engine.begin() as conn: + conn.execute(text("DROP TABLE logs_reversed")) + + +def test_seeded_sample_does_not_depend_on_scan_order(reversed_logs): + forward = SQLSource(reversed_logs, "SELECT line FROM logs", sample=0.2, sample_seed=42) + backward = SQLSource(reversed_logs, "SELECT line FROM logs_reversed", sample=0.2, sample_seed=42) + + sampled = set(forward) + + assert sampled == set(backward) + assert 0 < len(sampled) < 5000 + + class TestPostgreSQLSampling: def test_seeded_sample_is_deterministic_across_passes(self, pg_logs_engine): source = SQLSource(pg_logs_engine, "SELECT line FROM logs", sample=0.2, sample_seed=42) diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index e8a7e10..b94a8d4 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -16,23 +16,26 @@ class TestBuildSampleSql: - def test_postgresql_seeded_sets_seed_and_predicate(self): + def test_postgresql_seeded_hashes_the_row(self): setup, sql = build_sample_sql("postgresql", "SELECT msg FROM logs", 0.1, seed=42) - assert setup is not None - assert "setseed" in setup - assert "random() <" in sql + assert setup is None + assert "md5(_lr_sample::text || '42')" in sql + assert f"< {int(0.1 * 2**32)}" in sql assert "SELECT msg FROM logs" in sql # user query wrapped, not rewritten - assert "_lr_sample" in sql def test_postgresql_unseeded_has_no_setup(self): setup, sql = build_sample_sql("postgresql", "SELECT msg FROM logs", 0.25) assert setup is None assert "random() <" in sql - def test_mysql_uses_rand(self): - setup, sql = build_sample_sql("mysql", "SELECT msg FROM logs", 0.1, seed=7) + def test_mysql_seeded_hashes_the_named_columns(self): + setup, sql = build_sample_sql("mysql", "SELECT msg FROM logs", 0.1, seed=7, columns=["_lr_sample.`msg`"]) assert setup is None - assert "rand(7)" in sql + assert "MD5(CONCAT_WS(CHAR(31), _lr_sample.`msg`, '7'))" in sql + + def test_mysql_seeded_without_columns_raises(self): + with pytest.raises(ValueError, match="column names"): + build_sample_sql("mysql", "SELECT msg FROM logs", 0.1, seed=7) def test_fraction_one_is_valid(self): # 1.0 is the inclusive upper bound: sample everything. @@ -111,7 +114,7 @@ def test_postgresql_bad_method_raises(self): def test_mysql_predicate_fallback(self): sql = build_table_sample_query("mysql", "`logs`", "`msg`", fraction=0.1, seed=7) - assert "rand(7)" in sql + assert "MD5(CONCAT_WS(CHAR(31), `msg`, '7'))" in sql assert "TABLESAMPLE" not in sql def test_sqlite_predicate_and_seed_raises(self): From 7f7bf9df39af6c9276766b6012b4dfd97184fe29 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 18:00:19 +1100 Subject: [PATCH 16/21] docs: run the deployment recipes on Python 3.14 The Docker and Kubernetes recipes in examples/README.md used python:3.12-slim, which cannot install a package that requires 3.14. Both now use python:3.14-slim, and a test holds every image in that file to requires-python. --- examples/README.md | 4 ++-- tests/unit/test_examples.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_examples.py diff --git a/examples/README.md b/examples/README.md index b7d06bd..2d8198b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,7 +18,7 @@ sample log. ### Docker container ```dockerfile -FROM python:3.12-slim +FROM python:3.14-slim RUN pip install logreducer @@ -38,7 +38,7 @@ spec: spec: containers: - name: logreducer - image: python:3.12-slim + image: python:3.14-slim command: ["sh", "-c"] args: - | diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py new file mode 100644 index 0000000..9ce783b --- /dev/null +++ b/tests/unit/test_examples.py @@ -0,0 +1,16 @@ +"""The deployment recipes in examples/ install on the Python the package requires.""" + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_example_images_meet_requires_python(): + requires = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["requires-python"] + floor = tuple(int(part) for part in re.fullmatch(r">=(\d+)\.(\d+)", requires).groups()) + images = re.findall(r"python:(\d+)\.(\d+)", (ROOT / "examples" / "README.md").read_text(encoding="utf-8")) + + assert images + assert all((int(major), int(minor)) >= floor for major, minor in images), images From 15f3678672974c16a064078c74fb78d68d39d85e Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 18:21:23 +1100 Subject: [PATCH 17/21] fix: type-check against the Python 3.14 floor ty still targeted Python 3.12 after requires-python moved to 3.14. It now targets 3.14. That surfaced two things: an unguarded re.fullmatch in tests/unit/test_examples.py, now asserted first, and the drain3 imports in tests/testdata/build_quiet_golden.py, a reference script run under uvx with drain3 and now excluded from ty. --- pyproject.toml | 5 +++-- tests/unit/test_examples.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e799d1..18f0861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,10 +230,11 @@ default = true # ty: Astral type checker, runs alongside mypy. [tool.ty.environment] -python-version = "3.12" +python-version = "3.14" [tool.ty.src] -exclude = ["examples/", "docs/", "scripts/"] +# build_quiet_golden.py runs under uvx with drain3, which is never a dependency here. +exclude = ["examples/", "docs/", "scripts/", "tests/testdata/build_quiet_golden.py"] # Below 80 vulture reports the library's public API and attributes set on # third-party config objects, which buries a real leftover. diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index 9ce783b..0a934ce 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -9,7 +9,9 @@ def test_example_images_meet_requires_python(): requires = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["requires-python"] - floor = tuple(int(part) for part in re.fullmatch(r">=(\d+)\.(\d+)", requires).groups()) + floor_match = re.fullmatch(r">=(\d+)\.(\d+)", requires) + assert floor_match, requires + floor = tuple(int(part) for part in floor_match.groups()) images = re.findall(r"python:(\d+)\.(\d+)", (ROOT / "examples" / "README.md").read_text(encoding="utf-8")) assert images From 6e2e4ce9fa0aff9d5e0fa777db8af144f9f60244 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 18:25:31 +1100 Subject: [PATCH 18/21] test: run ClickHouse and Kafka tests on scalo's shared services The ClickHouse tests skipped wherever the docker ClickHouse failed to start, and each run started its own Redpanda. The suite now loads scalo.deployment.test_support.pytest_plugin (scalo>=2.32.4 in the dev group): one ClickHouse and one Kafka per run, shared by every xdist worker, with a database or topics per test, and a failure rather than a skip in CI. Kafka auto-create is off there, so the Kafka tests make single-partition topics through kafka_topics and name groups through kafka_group. PostgreSQL and MySQL stay on testcontainers. scalo 2.32.4 was released today, so it gets a per-package exclude-newer entry, due out on 2026-11-08. The certifi entry that expired on 2026-10-28 is removed. .env.example and CONTRIBUTING.md name the HYPERI_TEST_* settings in place of CLICKHOUSE_* and KAFKA_*. --- .env.example | 61 ++-- CONTRIBUTING.md | 19 +- pyproject.toml | 10 +- tests/conftest.py | 8 +- tests/integration/conftest.py | 265 ++---------------- tests/integration/corpora.py | 12 - tests/integration/test_clickhouse_source.py | 12 +- tests/integration/test_kafka_adapters.py | 79 +++--- tests/integration/test_real_datasets.py | 29 +- uv.lock | 292 ++++++++++++++++---- 10 files changed, 353 insertions(+), 434 deletions(-) diff --git a/.env.example b/.env.example index bb90df2..a7b6ab8 100644 --- a/.env.example +++ b/.env.example @@ -2,48 +2,33 @@ # # .env is GITIGNORED and must never be committed (it can hold a real # credential). Everything here is OPTIONAL: it is read only by the -# integration tests (tests/conftest.py loads .env via python-dotenv) and by -# the logging runtime. The library itself needs none of it. +# integration tests and by the logging runtime. The library itself needs none of it. # -# Optional env-prefix cascade (scalo convention): set LOGREDUCER_ENV_PREFIX to -# read _ before the bare name - e.g. LOGREDUCER_ENV_PREFIX="DFE" -# makes DFE_CLICKHOUSE_HOST win over CLICKHOUSE_HOST, so a .env written for -# another project drops in without renaming. -# LOGREDUCER_ENV_PREFIX="DFE" -# -# Test service selection is env-first, then docker: -# 1. if the vars below point at a reachable service, tests use it; -# 2. otherwise a throwaway docker container is started (ClickHouse, Redpanda -# for Kafka, PostgreSQL, MySQL) and cleaned up at the end; -# 3. if neither is available the dependent test skips. -# CI sets none of these -> it always uses docker. Tests create uniquely-named -# tables/topics and drop them afterwards, so pointing at a shared/persistent -# instance is safe. +# ClickHouse and Kafka for the integration tests come from scalo's shared test +# services: one container each per run, or an always-present server named by the +# HYPERI_TEST_* settings below when it answers (never in CI or under the full +# tier). Without Docker the test skips locally and fails in CI. Each test makes +# its own database or topics and drops them afterwards. -# --- ClickHouse (integration tests) ----------------------------------------- -# CLICKHOUSE_HOST="localhost" -# CLICKHOUSE_PORT="8123" -# CLICKHOUSE_USER="default" -# CLICKHOUSE_PASSWORD="" -# CLICKHOUSE_SECURE="false" # true = TLS -# CLICKHOUSE_VERIFY="true" # false = skip TLS certificate verification +# --- ClickHouse pet (integration tests) ------------------------------------- +# HYPERI_TEST_CLICKHOUSE_HOST="localhost" +# HYPERI_TEST_CLICKHOUSE_HTTP_PORT="8123" +# HYPERI_TEST_CLICKHOUSE_USER="default" +# HYPERI_TEST_CLICKHOUSE_PASSWORD="" +# HYPERI_TEST_CLICKHOUSE_CLUSTER="default" -# --- Kafka (integration tests) ---------------------------------------------- -# A bare bootstrap is a plain (no-auth) broker. Add KAFKA_SECURITY_PROTOCOL to -# reach an authenticated broker (SASL_SSL/SASL_PLAINTEXT); the SASL_* + SSL vars -# are then merged into the librdkafka client config. -# KAFKA_BOOTSTRAP_SERVERS="localhost:9092" -# KAFKA_SECURITY_PROTOCOL="SASL_SSL" # e.g. SASL_SSL | SASL_PLAINTEXT -# KAFKA_SASL_MECHANISM="SCRAM-SHA-512" -# KAFKA_SASL_USERNAME="user" -# KAFKA_SASL_PASSWORD="" -# KAFKA_SSL_CA_LOCATION="/path/to/ca.pem" # CA bundle for TLS verification -# KAFKA_SSL_VERIFY="true" # false = skip TLS cert verification +# --- Kafka pet (integration tests) ------------------------------------------ +# HYPERI_TEST_KAFKA_BOOTSTRAP="localhost:9092" +# HYPERI_TEST_KAFKA_SECURITY_PROTOCOL="SASL_SSL" +# HYPERI_TEST_KAFKA_SASL_MECHANISM="SCRAM-SHA-512" +# HYPERI_TEST_KAFKA_USER="user" +# HYPERI_TEST_KAFKA_PASSWORD="" +# HYPERI_TEST_KAFKA_CA_FILE="/path/to/ca.pem" +# HYPERI_TEST_KAFKA_TOPIC_PREFIX="test-" -# --- Docker test containers (optional) -------------------------------------- -# Keep the throwaway docker services RUNNING after the run (default: stop + -# remove). Handy for fast re-runs - export the printed endpoint as the vars -# above so the next run takes the env-first path. +# --- PostgreSQL and MySQL test containers (optional) ------------------------ +# Keep the throwaway PostgreSQL and MySQL containers RUNNING after the run +# (default: stop + remove). # LOGREDUCER_KEEP_CONTAINERS="1" # --- Logging (runtime, optional) --------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9d177e..f5b8397 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,23 +37,8 @@ uv build # build wheel + sdist internal code). `tests/integration/` - marked `integration`, run against real services. `tests/testdata/` holds gzipped, PII-cleansed slices of REAL public log datasets (see its README for provenance and the rebuild tool). -- Integration tests find their services env-first, then docker, then skip: - -```mermaid -flowchart TD - ENV{"env vars set?
(CLICKHOUSE_* /
KAFKA_BOOTSTRAP_SERVERS)"} -->|yes| PROBE{"endpoint
reachable?"} - PROBE -->|yes| USE(["use the configured service
(unique table/topic names,
dropped afterwards)"]) - PROBE -->|"no (warns why)"| DOCKER - ENV -->|no| DOCKER{"Docker
available?"} - DOCKER -->|yes| TC(["throwaway testcontainer:
ClickHouse / Redpanda /
PostgreSQL / MySQL"]) - TC --> STOP(["stopped at session end
(LOGREDUCER_KEEP_CONTAINERS=1
keeps it + prints the endpoint)"]) - DOCKER -->|no| SKIP([pytest.skip]) -``` - -- Copy `.env.example` to `.env` to point tests at existing services. CI sets - no env vars, so it always takes the docker path. -- Every test that touches a shared service creates uniquely-named tables and - topics and cleans them up, so runs never collide. +- ClickHouse and Kafka come from scalo's shared test services (`scalo.deployment.test_support.pytest_plugin`): one container each per run, shared by every xdist worker, or the always-present server the `HYPERI_TEST_*` settings in `.env.example` name. Each test gets its own database or topics, dropped afterwards. Without Docker those tests skip locally and fail in CI. +- PostgreSQL and MySQL are throwaway testcontainers, stopped at the end of the session unless `LOGREDUCER_KEEP_CONTAINERS=1`, and skip without Docker. ## Commit messages diff --git a/pyproject.toml b/pyproject.toml index 18f0861..365eec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,6 +171,7 @@ markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", ] +shared_service_repo = "logreducer" # Coverage configuration [tool.coverage.run] @@ -218,9 +219,9 @@ index-strategy = "unsafe-best-match" # version published in the last 7 days. A CVE fix bypasses it through a # temporary per-package exclude-newer-package entry, not by lowering this. exclude-newer = "7 days" -# certifi PYSEC-2026-4320 is fixed in 2026.10.19. A per-package entry replaces the -# global span rather than widening it, so remove this one on 2026-10-28. -exclude-newer-package = { certifi = "2026-10-21T00:00:00Z" } +# scalo 2.32.4 carries the shared test services the suite needs, released 2026-11-01. A +# per-package entry replaces the global span rather than widening it, so remove this one on 2026-11-08. +exclude-newer-package = { scalo = "2026-11-01T06:00:00Z" } override-dependencies = [] [[tool.uv.index]] @@ -274,7 +275,8 @@ dev = [ "sqlalchemy>=2.1.3", # SQLSource against SQLite (real, no server) "clickhouse-connect>=1.9.0", # ClickHouseSource (local cluster or docker) "confluent-kafka>=2.15.1", # KafkaSource/KafkaSink - "testcontainers[clickhouse,kafka,postgres,mysql]>=4.15.0", # docker fallback; modules under testcontainers.community + "scalo>=2.32.4", # the run's shared ClickHouse and Kafka (scalo.deployment.test_support) + "testcontainers[postgres,mysql]>=4.15.0", # PostgreSQL and MySQL, which scalo does not provide "psycopg[binary]>=3.2", # PostgreSQL driver (real TABLESAMPLE/setseed sampling tests) "pymysql>=1.1.0", # MySQL driver (real RAND(seed) sampling tests) "python-dotenv>=1.0.0", # load the local .env (CLICKHOUSE_*) in tests diff --git a/tests/conftest.py b/tests/conftest.py index 18f86d7..eb5bcfc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,10 +9,10 @@ import pytest -# Load the local .env (CLICKHOUSE_*, KAFKA_BOOTSTRAP_SERVERS, ...) so the -# integration tests can reach a real local-network service when one is -# configured. An absent or empty .env is fine - those tests then fall back to a -# throwaway docker container, or skip if docker is unavailable too. +# The run's one ClickHouse and Kafka, shared by every xdist worker. +pytest_plugins = ["scalo.deployment.test_support.pytest_plugin"] + +# Load the local .env so a test can read local settings. An absent .env is fine. try: from dotenv import load_dotenv diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9b34934..0648824 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,65 +1,22 @@ -"""Integration-test fixtures: real services, env-first, docker fallback. +"""Integration-test fixtures: real services, never mocks. -The ClickHouse and Kafka fixtures prefer a configured endpoint (env vars, -typically loaded from the project ``.env`` - see .env.example) and fall back to -a throwaway docker container; PostgreSQL and MySQL are docker-only (they exist -solely to exercise per-dialect sampling). Containers are stopped the moment the -session ends unless LOGREDUCER_KEEP_CONTAINERS=1. If neither an endpoint nor -docker is available, dependent tests skip rather than fail. +ClickHouse and Kafka are the run's shared services from scalo's test plugin (loaded in the +root ``conftest.py``): one container each, shared by every xdist worker, or the pet the +``HYPERI_TEST_*`` settings name. Each test gets a database or topics of its own. PostgreSQL +and MySQL, which the plugin does not provide, are throwaway testcontainers stopped when the +session ends unless LOGREDUCER_KEEP_CONTAINERS=1, and skip without Docker. """ -from __future__ import annotations - import contextlib import os -import time from collections.abc import Iterator from typing import Any import pytest -def _prefixed(names: tuple[str, ...]) -> tuple[str, ...]: - """Expand names with the optional env-prefix cascade. - - LOGREDUCER_ENV_PREFIX="DFE" makes DFE_CLICKHOUSE_HOST win over - CLICKHOUSE_HOST (prefixed first, bare fallback) - the scalo config-cascade - convention, so a .env written for another project (e.g. dfe-engine's - DFE_* names) drops in without renaming. - """ - prefix = os.environ.get("LOGREDUCER_ENV_PREFIX", "").strip().rstrip("_") - if not prefix: - return names - expanded: list[str] = [] - for name in names: - expanded.append(f"{prefix}_{name}") - expanded.append(name) - return tuple(expanded) - - -def _env_bool(name: str, default: bool = False) -> bool: - for candidate in _prefixed((name,)): - value = os.environ.get(candidate) - if value is not None: - return value.strip().lower() in ("1", "true", "yes", "on") - return default - - -def _env_first(*names: str, default: str = "") -> str: - for name in _prefixed(names): - value = os.environ.get(name) - if value: - return value - return default - - def _keep_containers() -> bool: - """LOGREDUCER_KEEP_CONTAINERS=1 leaves docker services RUNNING after the run. - - Default is to stop/remove them. Keeping them lets you re-run fast: grab the - printed endpoint and export it (KAFKA_BOOTSTRAP_SERVERS / CLICKHOUSE_* / a - DB URL) so the next run takes the env-first path and skips container startup. - """ + """LOGREDUCER_KEEP_CONTAINERS=1 leaves the PostgreSQL and MySQL containers RUNNING after the run.""" return os.environ.get("LOGREDUCER_KEEP_CONTAINERS", "").strip().lower() in ("1", "true", "yes", "on") @@ -73,205 +30,39 @@ def _stop(container: Any, label: str, endpoint: str) -> None: # --------------------------------------------------------------------------- -# ClickHouse +# ClickHouse and Kafka - the run's shared services # --------------------------------------------------------------------------- -def _clickhouse_from_env() -> Any | None: - """Return a connected clickhouse-connect client from env config, or None. - - Reads the CLICKHOUSE_* names (see .env.example). Returns None if unset - or unreachable. - """ - host = _env_first("CLICKHOUSE_HOST") - if not host: - return None - try: - import clickhouse_connect - - client = clickhouse_connect.get_client( - host=host, - port=int(_env_first("CLICKHOUSE_PORT", default="8123")), - username=_env_first("CLICKHOUSE_USER", default="default"), - password=_env_first("CLICKHOUSE_PASSWORD"), - secure=_env_bool("CLICKHOUSE_SECURE"), - verify=_env_bool("CLICKHOUSE_VERIFY", default=True), - connect_timeout=5, - send_receive_timeout=10, - ) - client.command("SELECT 1") - return client - except Exception as exc: - # Say WHY the configured endpoint was skipped - a typo'd password - # silently falling back to docker is painful to debug. - print(f"\n[env] CLICKHOUSE_HOST={host} configured but unusable ({exc}); falling back to docker") - return None - - -def _await_clickhouse_http(host: str, port: int, timeout: float = 45.0) -> Any: - """Poll the HTTP interface until a query succeeds (real readiness signal).""" +@pytest.fixture +def clickhouse_client(shared_clickhouse: Any, clickhouse_database: str) -> Iterator[Any]: + """A clickhouse-connect client on the run's ClickHouse, in a database of this test's own.""" import clickhouse_connect - deadline = time.monotonic() + timeout - last_exc: Exception | None = None - while time.monotonic() < deadline: - try: - client = clickhouse_connect.get_client(host=host, port=port, username="default", password="") - client.command("SELECT 1") - return client - except Exception as exc: # container HTTP not up yet - back off and retry - last_exc = exc - time.sleep(1.0) - raise TimeoutError(f"ClickHouse HTTP not ready on {host}:{port}") from last_exc - - -def _clickhouse_from_docker() -> tuple[Any, Any] | None: - """Start a throwaway ClickHouse container; return (client, container) or None. - - Uses a raw DockerContainer over the HTTP port so only clickhouse-connect is - needed (no native clickhouse-driver). Readiness gates on the server's own - "Ready for connections" log line plus a successful query. - """ - try: - from testcontainers.core.container import DockerContainer - from testcontainers.core.wait_strategies import LogMessageWaitStrategy - except ImportError: - return None - try: - container = ( - DockerContainer("clickhouse/clickhouse-server:24.8.14") - .with_exposed_ports(8123) - .waiting_for(LogMessageWaitStrategy("Ready for connections").with_startup_timeout(60)) - ) - container.start() - # Inside the try: if the HTTP probe times out, the container must - # still be stopped rather than leaked as a fixture ERROR. - host = container.get_container_host_ip() - port = int(container.get_exposed_port(8123)) - client = _await_clickhouse_http(host, port) - except Exception: - with contextlib.suppress(Exception): - container.stop() - return None - return client, container - - -@pytest.fixture(scope="session") -def clickhouse_client() -> Iterator[Any]: - """A ClickHouse client - local-network if configured, else docker, else skip.""" - client = _clickhouse_from_env() - if client is not None: - try: - yield client - finally: - with contextlib.suppress(Exception): - client.close() - return - - docker = _clickhouse_from_docker() - if docker is None: - pytest.skip("no ClickHouse: set CLICKHOUSE_* in the local .env, or start Docker") - client, container = docker + options: dict[str, Any] = {"secure": shared_clickhouse.secure} + if shared_clickhouse.ca_file: + options["ca_cert"] = shared_clickhouse.ca_file + client = clickhouse_connect.get_client( + host=shared_clickhouse.host, + port=shared_clickhouse.http_port, + username=shared_clickhouse.user, + password=shared_clickhouse.password, + database=clickhouse_database, + **options, + ) try: yield client finally: - with contextlib.suppress(Exception): - client.close() - endpoint = f"{container.get_container_host_ip()}:{container.get_exposed_port(8123)}" - _stop(container, "ClickHouse", endpoint) - - -# --------------------------------------------------------------------------- -# Kafka -# --------------------------------------------------------------------------- - - -def _kafka_config_from_env() -> str | dict[str, Any] | None: - """Return a reachable Kafka config from env, or None. - - A bare ``KAFKA_BOOTSTRAP_SERVERS`` yields a plain bootstrap string. Adding - ``KAFKA_SECURITY_PROTOCOL`` (e.g. SASL_SSL) upgrades it to a full librdkafka - config dict (SASL mechanism/username/password + TLS CA) - used to reach the - authenticated PET broker. Probed with an AdminClient before use. - """ - servers = _env_first("KAFKA_BOOTSTRAP_SERVERS") - if not servers: - return None - protocol = _env_first("KAFKA_SECURITY_PROTOCOL") - config: str | dict[str, Any] - if protocol: - config = {"bootstrap.servers": servers, "security.protocol": protocol} - for env_key, conf_key in ( - ("KAFKA_SASL_MECHANISM", "sasl.mechanism"), - ("KAFKA_SASL_USERNAME", "sasl.username"), - ("KAFKA_SASL_PASSWORD", "sasl.password"), - ("KAFKA_SSL_CA_LOCATION", "ssl.ca.location"), - ): - value = _env_first(env_key) - if value: - config[conf_key] = value - # Internal PET broker: skip TLS cert verification (same stance as - # CLICKHOUSE_VERIFY=false) - the chain uses an internal, pre-rebrand CA. - if not _env_bool("KAFKA_SSL_VERIFY", default=True): - config["enable.ssl.certificate.verification"] = "false" - else: - config = servers - try: - from confluent_kafka.admin import AdminClient - - probe = dict(config) if isinstance(config, dict) else {"bootstrap.servers": config} - probe["socket.timeout.ms"] = 6000 - AdminClient(probe).list_topics(timeout=8) - return config - except Exception as exc: - # Say WHY the configured broker was skipped rather than silently - # falling back to docker (bad SASL creds look identical otherwise). - print(f"\n[env] KAFKA_BOOTSTRAP_SERVERS={servers} configured but unusable ({exc}); falling back to docker") - return None + client.close() -def _redpanda_from_docker() -> tuple[str, Any] | None: - """Start a throwaway Redpanda container; return (bootstrap, container) or None. +@pytest.fixture +def kafka_bootstrap(shared_kafka: Any) -> dict[str, Any]: + """The librdkafka config for the run's Kafka, security settings included. - Redpanda is the docker broker (Kafka-API compatible, single binary, far - smaller/faster to start than Apache Kafka) - confluent-kafka talks to it - unchanged. + Topic auto-create is off there, so a test makes its topics with ``kafka_topics``. """ - try: - from testcontainers.community.kafka import RedpandaContainer - except ImportError: - return None - try: - container = RedpandaContainer() - container.start() - except Exception: - with contextlib.suppress(Exception): - container.stop() - return None - return container.get_bootstrap_server(), container - - -@pytest.fixture(scope="session") -def kafka_bootstrap() -> Iterator[str | dict[str, Any]]: - """A Kafka config - a configured broker (str, or a SASL dict for PET) if - reachable, else a Redpanda docker broker (str), else skip. - - The value is accepted directly by KafkaSource/KafkaSink and the corpora - loaders (all take a bootstrap string or a full librdkafka config dict). - """ - config = _kafka_config_from_env() - if config is not None: - yield config - return - - docker = _redpanda_from_docker() - if docker is None: - pytest.skip("no Kafka broker: set KAFKA_BOOTSTRAP_SERVERS, or start Docker (Redpanda)") - servers, container = docker - try: - yield servers - finally: - _stop(container, "Redpanda", servers) + return dict(shared_kafka.client_config) # --------------------------------------------------------------------------- diff --git a/tests/integration/corpora.py b/tests/integration/corpora.py index 548f223..4755e7a 100644 --- a/tests/integration/corpora.py +++ b/tests/integration/corpora.py @@ -137,15 +137,3 @@ def load_into_kafka(config: str | dict[str, Any], topic: str, name: str, *, limi if undelivered: raise RuntimeError(f"{undelivered} of {produced} messages not delivered to {topic} within 60s") return produced - - -def delete_kafka_topic(config: str | dict[str, Any], topic: str) -> None: - """Best-effort delete of a temp topic (cleanup on shared/persistent brokers).""" - import contextlib - - from confluent_kafka.admin import AdminClient - - admin = AdminClient(_kafka_conf(config)) - for future in admin.delete_topics([topic]).values(): - with contextlib.suppress(Exception): - future.result(timeout=30) diff --git a/tests/integration/test_clickhouse_source.py b/tests/integration/test_clickhouse_source.py index a5aae17..e3f9a32 100644 --- a/tests/integration/test_clickhouse_source.py +++ b/tests/integration/test_clickhouse_source.py @@ -1,13 +1,7 @@ -"""Integration test: ClickHouseSource against a real ClickHouse. +"""Integration test: ClickHouseSource against the run's shared ClickHouse. -Runs against a local/network ClickHouse when CLICKHOUSE_* is set (see -.env.example), otherwise a throwaway docker ClickHouse that is stopped as -soon as the session ends. Marked `integration`; deselect with -`-m "not integration"`. - -The query uses ClickHouse's built-in numbers() table function, so the test is -READ-ONLY: no table is created, nothing is written, nothing to clean up - safe -even against a shared cluster. +The query uses ClickHouse's built-in numbers() table function, so the test +writes nothing. Marked `integration`; deselect with `-m "not integration"`. """ import pytest diff --git a/tests/integration/test_kafka_adapters.py b/tests/integration/test_kafka_adapters.py index 3800efd..12046da 100644 --- a/tests/integration/test_kafka_adapters.py +++ b/tests/integration/test_kafka_adapters.py @@ -1,15 +1,10 @@ -"""Integration test: KafkaSource + KafkaSink round-trip against a real broker. +"""Integration test: KafkaSource + KafkaSink against the run's shared Kafka. -Runs against a local broker when KAFKA_BOOTSTRAP_SERVERS is set, otherwise a -throwaway docker Kafka that is stopped as soon as the session ends. Marked -`integration` so the default suite skips it. - -Each run uses a unique topic and consumer group, so it is safe against a shared -broker and leaves no durable state behind. +Each test makes its own single-partition topics and names its own consumer groups through +scalo's ``kafka_topics`` and ``kafka_group`` fixtures, so it is safe on a shared broker and +its topics are deleted afterwards. """ -import uuid - import pytest from logreducer import LogReducer @@ -17,22 +12,24 @@ pytestmark = pytest.mark.integration -def test_kafka_sink_then_source_roundtrip(kafka_bootstrap): +def _config(bootstrap: dict, **overrides) -> dict: + return {**bootstrap, **overrides} + + +# The consumer holds about 1 KB locally, so a paused read is far from the end of the topic. +THROTTLED = {"queued.max.messages.kbytes": 1, "max.partition.fetch.bytes": 1024} + + +def test_kafka_sink_then_source_roundtrip(kafka_bootstrap, kafka_topics, kafka_group): from logreducer.kafka import KafkaSink, KafkaSource - topic = f"logreducer-it-{uuid.uuid4().hex[:8]}" + topic = kafka_topics("roundtrip", partitions=1) lines = [f"ERROR request failed shard={i % 3}" for i in range(30)] - # Produce with the sink. produced = KafkaSink(kafka_bootstrap, topic).write(lines) assert produced == 30 - # Consume with the source (bounded read; stops at partition EOF). - source = KafkaSource( - kafka_bootstrap, - group_id=f"logreducer-it-{uuid.uuid4().hex[:8]}", - topics=topic, - ) + source = KafkaSource(kafka_bootstrap, group_id=kafka_group("roundtrip"), topics=topic) consumed = list(source) assert len(consumed) == 30 assert set(consumed) == set(lines) @@ -45,26 +42,13 @@ def test_kafka_sink_then_source_roundtrip(kafka_bootstrap): assert reducer.stats["input_lines"] == 30 -def _name() -> str: - return f"logreducer-it-{uuid.uuid4().hex[:8]}" - - -def _config(bootstrap, **overrides) -> dict: - config = {"bootstrap.servers": bootstrap} if isinstance(bootstrap, str) else dict(bootstrap) - return {**config, **overrides} - - -# The consumer holds about 1 KB locally, so a paused read is far from the end of the topic. -THROTTLED = {"queued.max.messages.kbytes": 1, "max.partition.fetch.bytes": 1024} - - -def test_source_stops_at_the_end_offsets_it_started_with(kafka_bootstrap): +def test_source_stops_at_the_end_offsets_it_started_with(kafka_bootstrap, kafka_topics, kafka_group): from logreducer.kafka import KafkaSink, KafkaSource - topic = _name() + topic = kafka_topics("end-offsets", partitions=1) lines = [f"INFO first batch {i}" for i in range(2_000)] KafkaSink(kafka_bootstrap, topic).write(lines) - source = KafkaSource(_config(kafka_bootstrap, **THROTTLED), group_id=_name(), topics=topic) + source = KafkaSource(_config(kafka_bootstrap, **THROTTLED), group_id=kafka_group("end-offsets"), topics=topic) consumed = [] for line in source: @@ -75,12 +59,12 @@ def test_source_stops_at_the_end_offsets_it_started_with(kafka_bootstrap): assert consumed == lines -def test_source_reads_from_the_start_whatever_its_group_committed(kafka_bootstrap): +def test_source_reads_from_the_start_whatever_its_group_committed(kafka_bootstrap, kafka_topics, kafka_group): from confluent_kafka import Consumer, TopicPartition from logreducer.kafka import KafkaSink, KafkaSource - topic, group = _name(), _name() + topic, group = kafka_topics("committed", partitions=1), kafka_group("committed") lines = [f"INFO line {i}" for i in range(100)] KafkaSink(kafka_bootstrap, topic).write(lines) # Another member of the group commits half way through the topic. @@ -93,49 +77,50 @@ def test_source_reads_from_the_start_whatever_its_group_committed(kafka_bootstra assert list(KafkaSource(kafka_bootstrap, group_id=group, topics=topic)) == lines -def test_empty_polls_before_the_end_do_not_end_the_read(kafka_bootstrap): +def test_empty_polls_before_the_end_do_not_end_the_read(kafka_bootstrap, kafka_topics, kafka_group): from logreducer.kafka import KafkaSink, KafkaSource - topic = _name() + topic = kafka_topics("empty-polls", partitions=1) lines = [f"INFO line {i}" for i in range(2_000)] KafkaSink(kafka_bootstrap, topic).write(lines) # A 1 ms poll returns empty between most fetches. - source = KafkaSource(_config(kafka_bootstrap, **THROTTLED), group_id=_name(), topics=topic, poll_timeout=0.001) + throttled = _config(kafka_bootstrap, **THROTTLED) + source = KafkaSource(throttled, group_id=kafka_group("empty-polls"), topics=topic, poll_timeout=0.001) assert list(source) == lines -def test_a_read_that_stalls_short_of_the_end_raises(kafka_bootstrap): +def test_a_read_that_stalls_short_of_the_end_raises(kafka_bootstrap, kafka_topics, kafka_group): from logreducer.kafka import KafkaConsumerError, KafkaSink, KafkaSource - topic = _name() + topic = kafka_topics("stall", partitions=1) KafkaSink(kafka_bootstrap, topic).write([f"INFO line {i}" for i in range(10)]) # The broker holds each fetch until 10 MB is ready or 20 s pass, so nothing arrives inside the idle timeout. stalled = _config(kafka_bootstrap, **{"fetch.min.bytes": 10_000_000, "fetch.wait.max.ms": 20_000}) - source = KafkaSource(stalled, group_id=_name(), topics=topic, idle_timeout=2.0) + source = KafkaSource(stalled, group_id=kafka_group("stall"), topics=topic, idle_timeout=2.0) with pytest.raises(KafkaConsumerError, match="stopped short of the end"): list(source) -def test_sink_waits_for_queue_space_rather_than_failing(kafka_bootstrap): +def test_sink_waits_for_queue_space_rather_than_failing(kafka_bootstrap, kafka_topics, kafka_group): from logreducer.kafka import KafkaSink, KafkaSource - topic = _name() + topic = kafka_topics("queue-full", partitions=1) lines = [f"INFO line {i}" for i in range(2_000)] # A 10-message local queue fills long before the broker acknowledges anything. sink = KafkaSink(_config(kafka_bootstrap, **{"queue.buffering.max.messages": 10}), topic) assert sink.write(lines) == 2_000 - assert sorted(KafkaSource(kafka_bootstrap, group_id=_name(), topics=topic)) == sorted(lines) + assert sorted(KafkaSource(kafka_bootstrap, group_id=kafka_group("queue-full"), topics=topic)) == sorted(lines) -def test_sink_flushes_what_it_queued_when_a_produce_fails(kafka_bootstrap): +def test_sink_flushes_what_it_queued_when_a_produce_fails(kafka_bootstrap, kafka_topics): from confluent_kafka import KafkaException from logreducer.kafka import KafkaSink - sink = KafkaSink(kafka_bootstrap, _name()) + sink = KafkaSink(kafka_bootstrap, kafka_topics("oversized", partitions=1)) oversized = "x" * 2_000_000 # over librdkafka's default message.max.bytes with pytest.raises(KafkaException): diff --git a/tests/integration/test_real_datasets.py b/tests/integration/test_real_datasets.py index 82b7718..6eb4ec2 100644 --- a/tests/integration/test_real_datasets.py +++ b/tests/integration/test_real_datasets.py @@ -2,7 +2,7 @@ Verifies the core reduction capability on varied, messy, real-world logs - and that the SAME data reduces consistently whether it arrives as an in-memory list, -from PostgreSQL (SQLSource) or from a Redpanda/Kafka topic (KafkaSource). +from PostgreSQL (SQLSource) or from a Kafka topic (KafkaSource). Docker-gated where a store is involved; the in-memory sweep needs no services. No mocks - real corpora, real engines/brokers via testcontainers. @@ -81,28 +81,19 @@ def test_reduce_from_clickhouse(clickhouse_client): corpora.drop_clickhouse_table(clickhouse_client, table) -def test_reduce_from_kafka(kafka_bootstrap): - """A dataset produced to a Kafka topic reduces via KafkaSource. - - Runs against whatever broker the fixture resolves: a configured broker - from the env (possibly SASL-authenticated) or the docker Redpanda fallback. - """ +def test_reduce_from_kafka(kafka_bootstrap, kafka_topics, kafka_group): + """A dataset produced to a Kafka topic reduces via KafkaSource.""" pytest.importorskip("confluent_kafka") from logreducer.kafka import KafkaSource - name = "loghub_openssh" - topic = corpora.unique_name("logreducer-real") - group = corpora.unique_name("logreducer-grp") - try: - produced = corpora.load_into_kafka(kafka_bootstrap, topic, name, limit=20000) - assert produced == 20000 + topic = kafka_topics("real-dataset") + produced = corpora.load_into_kafka(kafka_bootstrap, topic, "loghub_openssh", limit=20000) + assert produced == 20000 - source = KafkaSource(kafka_bootstrap, group_id=group, topics=topic, max_messages=20000) - reduced = LogReducer(level="standard", mode="pattern").reduce(source) - assert reduced - assert len(reduced) < produced # real reduction happened - finally: - corpora.delete_kafka_topic(kafka_bootstrap, topic) + source = KafkaSource(kafka_bootstrap, group_id=kafka_group("real-dataset"), topics=topic, max_messages=20000) + reduced = LogReducer(level="standard", mode="pattern").reduce(source) + assert reduced + assert len(reduced) < produced # real reduction happened def test_reduce_to_target_over_real_postgres(pg_engine): diff --git a/uv.lock b/uv.lock index fe54c93..a5e8a6b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" resolution-markers = [ "python_full_version >= '3.15'", @@ -11,7 +11,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -certifi = "2026-10-21T00:00:00Z" +scalo = "2026-11-01T06:00:00Z" [[package]] name = "annotated-doc" @@ -22,6 +22,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "ast-serialize" version = "0.12.1" @@ -86,6 +99,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, ] +[[package]] +name = "asyncer" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/e3/a9de31596d386b8e76e17c609e20775c6a414cff95d025551aca3c63f282/asyncer-0.0.18.tar.gz", hash = "sha256:dc233a466f85f1bc17dafcdb861b06291b64777a8d9076f7884467cbb66f95ef", size = 24503, upload-time = "2026-06-25T13:41:08.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/62/73cea402f7c55b830bc02850f84724d9f6e3538c68610f69016f6f480a94/asyncer-0.0.18-py3-none-any.whl", hash = "sha256:054be3c0c3d7f0f79aac5b7d2cf7ad3a740bc465b62a77b48716a30c22679962", size = 12099, upload-time = "2026-06-25T13:41:07.98Z" }, +] + [[package]] name = "bandit" version = "1.9.4" @@ -322,30 +349,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/59/0a47b35a47a723ef45c3c51a6469464e5937420fc601d2a9b014865228c6/clickhouse_connect-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ab9f291563f7cb10c9d26b4d190c918072e610ed8d228dcf48017f746b9e00dc", size = 437485, upload-time = "2026-09-22T15:09:12.108Z" }, ] -[[package]] -name = "clickhouse-driver" -version = "0.2.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytz" }, - { name = "tzlocal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/75/42c6c0f1e0b84213ff096f913ac0ff82037da6f06754bff28685b2d1e23a/clickhouse_driver-0.2.11.tar.gz", hash = "sha256:1bec70343bde9e9a55c2254c5960d34c682ff7d60256589226d96c67a112f95a", size = 434076, upload-time = "2026-07-17T18:31:46.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/3b/47c4143d003011aa3977b3ab3eafa2ae338cb483387886700c384488226a/clickhouse_driver-0.2.11-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3e1c3b08907e836de894054d4c66bcf1415cd0a3fe94e3e2c865abe43a635c82", size = 245301, upload-time = "2026-07-17T18:30:46.573Z" }, - { url = "https://files.pythonhosted.org/packages/2d/10/b1ba901d7c71360cafa594130ac43b65f5574e1b03a6fb0c3000c28b68ae/clickhouse_driver-0.2.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eb88ef5ed671e260a6493d7f16f12e21a4f2d04afcfb58c3200324f9621dc553", size = 248506, upload-time = "2026-07-17T18:30:47.866Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cb/68508736b1a537b48a64b053ffbd5b62bd7384ddee97e77efb392164cac7/clickhouse_driver-0.2.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b72c26343a2b946e589a4d5de259c3705f6969facfd4b283a6c5015fc67c92", size = 1057958, upload-time = "2026-07-17T18:30:49.364Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a8/8e4d7cb2ae58919313c6f4d8641a49c41dc15540572efbd88a46893c0f36/clickhouse_driver-0.2.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd3b1af7c7174428007b20bbec4c60699758e20cf6beba45c61039762a06116a", size = 1113297, upload-time = "2026-07-17T18:30:50.821Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/ae1be44941e46660aebd70d2ce05729a7edffa42663e31892c4a98910fb7/clickhouse_driver-0.2.11-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5540cfbae18997e625c4fd8ec9875da46c816143c6456cab9a11adfaed38cc34", size = 1119563, upload-time = "2026-07-17T18:30:52.402Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a9/52d086fa76adee9961434b7672706e0403ee09a0c3c49d5c713391d15e36/clickhouse_driver-0.2.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7c9152bfdfd4ebe0fb3b1c4320f39e3b88c8f4f3c6b3db625a5c0cb5b86f955", size = 1049416, upload-time = "2026-07-17T18:30:53.995Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0e/6aafbe06ac73d061554bdbc7dae28b564c0e17619b266e661e235a71b297/clickhouse_driver-0.2.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2fa0a4a72618f06c0ee308117253fe351161a562aea8b31641fcbf7d9ad6080d", size = 1022725, upload-time = "2026-07-17T18:30:55.478Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3a/97593f51c0ead217705c46e90f6f23522fd9f252a3f27c84acc38c04113b/clickhouse_driver-0.2.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:94593758fa36195fe56199422fe8188cabd6403c3950140cd8cffbd89af34f24", size = 1073586, upload-time = "2026-07-17T18:30:57.641Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d3/9c4a2b7d589e86ea635d85137ed3c5228069cdb780c1919be0f533728b72/clickhouse_driver-0.2.11-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:aa3961a892b94aaa83571e773347d7f36ab5e1334c6711bfd63f611b88c4c541", size = 1070303, upload-time = "2026-07-17T18:30:59.569Z" }, - { url = "https://files.pythonhosted.org/packages/76/58/005fa6ae82131404a1a0053043f7711332c01cf0c0e6863268f0be104e12/clickhouse_driver-0.2.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e09e4f2cff823027a222c5bd8d9b3b3ca7d70f26ef0dccd2e496820280333ed", size = 1024768, upload-time = "2026-07-17T18:31:01.282Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/334868a895896fc83262200f06b8e25c0573f2e5a7299ebbdb1fdbbb3230/clickhouse_driver-0.2.11-cp314-cp314-win32.whl", hash = "sha256:688a2cd31a7fd87a9f2a1bb669a04874b5ae17bbca22a373610e84aa241b1e9a", size = 220173, upload-time = "2026-07-17T18:31:02.759Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/900211ab9390f64edef10564a206e2dca89fc49a4191aee870d2c18002f6/clickhouse_driver-0.2.11-cp314-cp314-win_amd64.whl", hash = "sha256:0a67ce59def2e08cbda1cf12d9e8a7a6879cee8048ee46bbd415e9111033f775", size = 234021, upload-time = "2026-07-17T18:31:04.129Z" }, -] - [[package]] name = "cloudpickle" version = "3.1.2" @@ -546,6 +549,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + [[package]] name = "docker" version = "7.2.0" @@ -560,6 +576,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, ] +[[package]] +name = "dulwich" +version = "1.2.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/cf/0af9df98f5d117cd5cef1abedf8d91c7c0681286ea5984b9e792104631f8/dulwich-1.2.19.tar.gz", hash = "sha256:b3cd6b17bfdf7bbae0d04d0f46fc23ce9e175e8871884a1de344f92e2a623a15", size = 1435621, upload-time = "2026-10-22T10:06:55.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/b0/46a1aeccf9165bd7ef30a0b21d4b899f63172ff3a4ce38edaa3384609acc/dulwich-1.2.19-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:245d189b752ae73037d83a91e69715739e4c2e0a5882c38a3c089952a9ff3e13", size = 1588910, upload-time = "2026-10-22T10:06:07.304Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/6bb4ecc440e8937fb0183400802d0f774b9ac4a29e2d8e034d5854b9caf0/dulwich-1.2.19-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6f6934299c81464b12f475150e5ecee0674fa89915eb94f1866387bf5810b50e", size = 1644322, upload-time = "2026-10-22T10:06:08.775Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/57b7eaccd527f17d217e5c5745f614844d4955f626836a1f779ee0105df4/dulwich-1.2.19-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e093fe41e1843c2175cc9e5ca7bdb2356e5bd2a819b3b5f5eb3c8ed980617443", size = 1440513, upload-time = "2026-10-22T10:06:10.419Z" }, + { url = "https://files.pythonhosted.org/packages/6a/77/fc117f193e71aa07a2e4c03bc67c57579bcd4ec77325657af55fef6d7a57/dulwich-1.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5c7ab399bcf7a76a1989a85872ab719e284d2f22b44952a9489c87e214a6ae85", size = 1421945, upload-time = "2026-10-22T10:06:11.894Z" }, + { url = "https://files.pythonhosted.org/packages/e2/68/6f88ae82d27624673152dac20f57eb6b8155f63e5cdd5c5be47ed4ba947b/dulwich-1.2.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25cb4f98ddbe54c9b8d3757d53a1bbee98cd64f4c4b3d0a4dc9d920a46bdb7e7", size = 1556730, upload-time = "2026-10-22T10:06:13.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/a5/d4187fd0491a2a9e2fa66ad7695991fb2d07f3a7f7afb0ac190f9529122f/dulwich-1.2.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:61a079c10d46e7e7a4bdfdef77995ee8521a94964ceaa0aba3a7ad3129802e6f", size = 1532490, upload-time = "2026-10-22T10:06:14.943Z" }, + { url = "https://files.pythonhosted.org/packages/71/77/533a170c78ff33885064a7fc29e7d7ad5330fe70f921f7f41793e703bf00/dulwich-1.2.19-cp314-cp314-win32.whl", hash = "sha256:e7fb76abd2a11c596539f52916c0acdaf3211f5ecd34235e79b58fb44fc8a23c", size = 1170582, upload-time = "2026-10-22T10:06:17.143Z" }, + { url = "https://files.pythonhosted.org/packages/59/b9/e9806747eb4169940e6237ff62f863e57397a1349e95465045cd7e2c06be/dulwich-1.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:1145ee36c4668e47cccd3b26f340a2efee5c20ac274915d8c57313aef41f4fce", size = 1127567, upload-time = "2026-10-22T10:06:18.789Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/7fffc5adab5b68e2fc1d3da785ce7b5b77b1dad11b3da52900cce275ef07/dulwich-1.2.19-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c844db4c27661f242b87ca78252e5764216ef9b09fe820c5b91b3148fc0324c1", size = 1501695, upload-time = "2026-10-22T10:06:20.799Z" }, + { url = "https://files.pythonhosted.org/packages/69/81/6d4e84f3db54f6fc59344261fab46c09461c43e21cbb27b67ead5abd741e/dulwich-1.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c56985f81c7eb1f4ddc7264406db8cf8b1e07b2d071b553080ac64679d28cba6", size = 1419374, upload-time = "2026-10-22T10:06:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/bc0e533687be0f8542eecf5c488df20844311930f513ec62a3264bce09ff/dulwich-1.2.19-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2a08915edc83ccf63521cd0dfcd1011f1cb3e56f874fc147432865fea2b0ab84", size = 1553462, upload-time = "2026-10-22T10:06:23.725Z" }, + { url = "https://files.pythonhosted.org/packages/65/07/ff75b9b759795e4970d414ebf82a6cc894544d72db41592597da6ef28317/dulwich-1.2.19-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e97c739826aaeb2dfa1232cedbe8e83107c4a133c7faa8e3d239cc8bbcb526de", size = 1532484, upload-time = "2026-10-22T10:06:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/05/ed/d54f1dbbbbb985e2a4af2b95cfa0d30b881c1b509f4df26463425283e93e/dulwich-1.2.19-cp314-cp314t-win32.whl", hash = "sha256:33ef5ff30b561fa216e1ce44eb0e9d33c989c0f8d63999c11d9b596ebb472eb3", size = 1110753, upload-time = "2026-10-22T10:06:27.237Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/cf8c98699a3d36021cea36a0ad657f2fbf04081d675e1fa29a268119b54a/dulwich-1.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:17a24dfdd0544e6e63bb70ea9d9eadcc831b24ae70bc85fa978ed4ad7340d59a", size = 1128110, upload-time = "2026-10-22T10:06:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/9c/13/14ab2211b3524fceb646667bf8524ea36419864f9bedbe74be1381ba6c2e/dulwich-1.2.19-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:20d592bc9e6b2f2aa0354b6b92589d11196833abc9fd134ba36fb2e7555a8e99", size = 1590451, upload-time = "2026-10-22T10:06:30.399Z" }, + { url = "https://files.pythonhosted.org/packages/68/c4/6ebb71babe0c5045acd4337d7a0622051843a3b37e1336e569789b9a3b3a/dulwich-1.2.19-cp315-cp315-android_24_x86_64.whl", hash = "sha256:63dee71ef10af8aabc7814283b34758298e0abd96010a78937641a9d4b7dd980", size = 1645626, upload-time = "2026-10-22T10:06:32.386Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2e/82713e6cf226f43b42fb3a0e8c0d246eb81bfd0fbe74a90582f33c2eb883/dulwich-1.2.19-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:504ce2688513110864ef1c38119f08198e0c71a369e6769b12d005af84871ea8", size = 1442029, upload-time = "2026-10-22T10:06:34.095Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/f1fd06c0bd2c8946514e5e44e18d73d66b263e5c6883ae226e833c12341e/dulwich-1.2.19-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:705e84bc212da709f1af97fe81d170f371ef82209ab6049242cd41a18cac50f6", size = 1422820, upload-time = "2026-10-22T10:06:35.58Z" }, + { url = "https://files.pythonhosted.org/packages/f1/33/15d855be105a44079e817a28b861df36ef022e0b00deda98b758cbfa7d85/dulwich-1.2.19-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:5c29f2affb4983bec7dce075d79f974e1def37ea2c20ad836ee37a18a699bb5f", size = 1557281, upload-time = "2026-10-22T10:06:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/e8/af/ca55ba2b70efbe26b6ab3ff02570adada5554bde584ac1b005ed299a2939/dulwich-1.2.19-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:b21034e52c1040ea49565f35c481b00852c3a16ace39e5a6c9c5ce172bfd6652", size = 1533280, upload-time = "2026-10-22T10:06:38.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/68/b97bb42933d60769e9c4078ea5173b78152280d5a384e6a11a39f2471771/dulwich-1.2.19-cp315-cp315-win32.whl", hash = "sha256:4022fa323b5b83ad14d63fcc7cf5e8c2ec2a6d60971915c4414ce13857b5d396", size = 1171035, upload-time = "2026-10-22T10:06:40.613Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/c84e0d84acd0e677ae2f9940580a2e4ded810a43558bad971fd3cb326737/dulwich-1.2.19-cp315-cp315-win_amd64.whl", hash = "sha256:b103523bbe0454594174069d4672bff86e91d0976ba690a6457f4677c2cadab6", size = 1127786, upload-time = "2026-10-22T10:06:42.095Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/735e8c1b0c4056b914045c030597689514fa9d61f15866a70cd02096e2b2/dulwich-1.2.19-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:04a22388664813857aa72db2892f315a1d018ab864e72775dfac0a90c026f5f1", size = 1502593, upload-time = "2026-10-22T10:06:43.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/18/cc3bd0d5317a18c4014eeaf7cdad4f596dc2c5a091d5a12c042b96a64f0e/dulwich-1.2.19-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ac5de1b2e3ec51c2b97ce5892f414095ae48078f0a4169902f7175556abce80", size = 1478815, upload-time = "2026-10-22T10:06:45.258Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/daf17f6eba7f532efbd272597b36d71e2e98b59aafcfe679fff94b3a3422/dulwich-1.2.19-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:095f7994c3eb069c6fd6b8c7fa305219d431b2b3ead04bbdc10b032b2b628ade", size = 1554900, upload-time = "2026-10-22T10:06:46.794Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/db595e1787ef848add6b491ee61a0688a053e6421ee42a93c3eeb611a95a/dulwich-1.2.19-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:39e150ea7f96a5600d94be676d065e796dce90826dd315bf8b83a315a202c38a", size = 1533270, upload-time = "2026-10-22T10:06:48.593Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/441199f5970812a82783c8841d579b6956dd62279e1117237f055bd23368/dulwich-1.2.19-cp315-cp315t-win32.whl", hash = "sha256:b1e763816c16637cc0ccfdb6d6c927e41e4ff40c7d52bf69017360a33b42cffd", size = 1111024, upload-time = "2026-10-22T10:06:50.19Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/1ba5ff4a5cba18360bc52ee098d7116e8e4fedb8a153b54923a4ec8efae1/dulwich-1.2.19-cp315-cp315t-win_amd64.whl", hash = "sha256:3983613d7027c60085bf9e3b3ff05591fb48ec34db5498e25de4e708dcb37039", size = 1128824, upload-time = "2026-10-22T10:06:51.871Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/f8abd5626e413c356a70aef0804893895dbc75de770cec87dba72302c577/dulwich-1.2.19-py3-none-any.whl", hash = "sha256:efe585c9c2a921a26136dc2fba785faafb16d5eed39ea6d40c5dd1cd8140f0a3", size = 755694, upload-time = "2026-10-22T10:06:53.357Z" }, +] + +[[package]] +name = "dynaconf" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/e4/723ba469856bb493c948985e5bd562c8a65f2b93e70c896e9764ab76de00/dynaconf-3.3.5.tar.gz", hash = "sha256:a08f6ab44025034ef3c9f86b32548ab01efd4039094a74bd9f028a43c63d016f", size = 333426, upload-time = "2026-08-05T19:50:25.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/41/b38501c34c40c3261868a2af8ed5fffe3e6d889c680638708de27eb57788/dynaconf-3.3.5-py3-none-any.whl", hash = "sha256:b3b4a753d5c866fc85466b65fc58d4954f6ed7dc8aa67f2b15bcbfd85d184687", size = 271426, upload-time = "2026-08-05T19:50:23.802Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -731,8 +796,9 @@ dev = [ { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "ruff" }, + { name = "scalo" }, { name = "sqlalchemy" }, - { name = "testcontainers", extra = ["clickhouse", "mysql"] }, + { name = "testcontainers", extra = ["mysql"] }, { name = "ty" }, { name = "vermin" }, { name = "vulture" }, @@ -768,14 +834,28 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.16.10" }, + { name = "scalo", specifier = ">=2.32.4" }, { name = "sqlalchemy", specifier = ">=2.1.3" }, - { name = "testcontainers", extras = ["clickhouse", "kafka", "postgres", "mysql"], specifier = ">=4.15.0" }, + { name = "testcontainers", extras = ["postgres", "mysql"], specifier = ">=4.15.0" }, { name = "ty", specifier = ">=0.0.34" }, { name = "vermin", specifier = ">=1.6.0" }, { name = "vulture", specifier = ">=2.16" }, { name = "xxhash", specifier = ">=4.0.2" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "lz4" version = "4.4.5" @@ -813,6 +893,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + [[package]] name = "msgpack" version = "1.2.3" @@ -1005,6 +1094,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "phonenumbers" +version = "9.0.41" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/df/cc0d70f1c79e436ea00d935b6352053d526252b81ce6c130d39eee846fb2/phonenumbers-9.0.41.tar.gz", hash = "sha256:dfa6f74eeac67c044b75313fe0af10774d7d1e1242241437279d4c2fb8027c01", size = 2311017, upload-time = "2026-10-08T10:51:09.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/1a/4059026e9c8a4c3faeab4a45f5aa67fb77d1f0d9c767085ded69c5c533e2/phonenumbers-9.0.41-py2.py3-none-any.whl", hash = "sha256:ccf2ea44f8aa35c487f26146a31520ecedf8e1af1f57c803678ecb5ef5c01668", size = 2594658, upload-time = "2026-10-08T10:51:07.317Z" }, +] + [[package]] name = "pip" version = "26.2.1" @@ -1252,12 +1350,12 @@ wheels = [ ] [[package]] -name = "pytz" -version = "2026.5" +name = "python-stdnum" +version = "2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/7f/96c2b9de6024353177dc6139c33730d5ac25877bc33215515d6b95b84555/python_stdnum-2.2.tar.gz", hash = "sha256:e95fcfa858a703d4a40130cb3eaac133c60d8808a7f3c98efeedac968c2479b9", size = 1311813, upload-time = "2026-01-04T19:36:16.753Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/2f/61/aa32d9c79f83a2fae033cd6496fb2a24aba918d31c73704271dfcfb48375/python_stdnum-2.2-py3-none-any.whl", hash = "sha256:bdf98fd117a0ca152e4047aa8ad254bae63853d4e915ddd4e0effb33ba0e9260", size = 1193213, upload-time = "2026-01-04T19:36:14.812Z" }, ] [[package]] @@ -1299,6 +1397,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "regex" +version = "2026.9.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, + { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, + { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" }, + { url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" }, + { url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" }, + { url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" }, + { url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" }, + { url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" }, + { url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1352,6 +1522,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] +[[package]] +name = "scalo" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "asyncer" }, + { name = "detect-secrets" }, + { name = "dulwich" }, + { name = "dynaconf" }, + { name = "loguru" }, + { name = "mergedeep" }, + { name = "phonenumbers" }, + { name = "python-dotenv" }, + { name = "python-stdnum" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tomli-w" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/c3/0fd22195a16851d11368a090fe5a110c7e81fef2bc608b14df0eb2b9ba26/scalo-2.32.4.tar.gz", hash = "sha256:d5cc1c9dd5cd14c19dca996de6d564223669b067b80f2ecefb0de7f3e810c830", size = 548113, upload-time = "2026-11-01T05:14:03.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/bd/311ad62baf5b09b616500434d89bbe7fc35a33eb0017a3ecd4f99a09d2cd/scalo-2.32.4-py3-none-any.whl", hash = "sha256:bc58c89f6814c629717f2fb9e22be650c38c94e3da656ae3522938e628e6019f", size = 640615, upload-time = "2026-11-01T05:14:00.012Z" }, +] + [[package]] name = "scikit-learn" version = "1.9.1" @@ -1451,6 +1646,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -1538,9 +1742,6 @@ wheels = [ ] [package.optional-dependencies] -clickhouse = [ - { name = "clickhouse-driver" }, -] mysql = [ { name = "pymysql", extra = ["rsa"] }, { name = "sqlalchemy" }, @@ -1649,18 +1850,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] -[[package]] -name = "tzlocal" -version = "5.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, -] - [[package]] name = "urllib3" version = "2.8.0" @@ -1688,6 +1877,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "wrapt" version = "2.5.0" From 96e8710e5337bea581fa8b1bf79215bb96eb48b4 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 19:15:23 +1100 Subject: [PATCH 19/21] fix: target Python 3.14 in lint and type checks Change ruff, mypy and vermin targets from Python 3.12 to 3.14 to match requires-python. - ruff drops the quotes from two forward-reference annotations (UP037) and formats two except clauses without brackets (PEP 758). - Annotate the empty list in core.py as list[str]. mypy requires this at 3.12 too. - No compatibility issues at 3.14. vermin 1.8.0 ignores [tool.vermin] in pyproject.toml, so that block only documents the target. --- pyproject.toml | 6 +++--- src/logreducer/cli.py | 4 ++-- src/logreducer/config.py | 2 +- src/logreducer/core.py | 2 +- src/logreducer/patterns.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 365eec1..ab11286 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ module-root = "src" # Ruff: lint + format (replaces black + flake8 + isort). Line length 120. [tool.ruff] -target-version = "py312" +target-version = "py314" line-length = 120 indent-width = 4 src = ["src"] @@ -144,7 +144,7 @@ line-ending = "auto" # (datasketch, ...); our own code stays fully typed via # disallow_untyped_defs below. [tool.mypy] -python_version = "3.12" # Held below requires-python with ruff; see target-version +python_version = "3.14" ignore_missing_imports = true warn_return_any = true warn_unused_configs = true @@ -244,7 +244,7 @@ min_confidence = 80 # vermin: verify no syntax/features newer than the minimum Python. [tool.vermin] -targets = ["3.12"] +targets = ["3.14"] no_tips = true [tool.bandit.assert_used] diff --git a/src/logreducer/cli.py b/src/logreducer/cli.py index ce195c6..bd753d5 100644 --- a/src/logreducer/cli.py +++ b/src/logreducer/cli.py @@ -189,7 +189,7 @@ def _reduce( else: _err("Error: provide a log file, or --dsn with --query (SQL/ClickHouse) or --topic/--group (Kafka)") raise typer.Exit(1) - except (typer.Exit, typer.BadParameter): + except typer.Exit, typer.BadParameter: raise # our control-flow exit / typer usage errors - let typer format them except KeyboardInterrupt: _err("Processing interrupted by user") @@ -276,7 +276,7 @@ def _run_target( close = getattr(source, "close", None) if callable(close): close() - except (typer.Exit, typer.BadParameter): + except typer.Exit, typer.BadParameter: raise except KeyboardInterrupt: _err("Processing interrupted by user") diff --git a/src/logreducer/config.py b/src/logreducer/config.py index 8ea3bd9..f5ee244 100644 --- a/src/logreducer/config.py +++ b/src/logreducer/config.py @@ -102,7 +102,7 @@ def __post_init__(self) -> None: self.max_memory_gb = clamped @classmethod - def from_env(cls, *prefixes: str) -> "BigDialConfig": + def from_env(cls, *prefixes: str) -> BigDialConfig: """Build a config from environment variables, cascade-aware. ``from_env()`` reads ``LOGREDUCER_`` (upper-cased field names, diff --git a/src/logreducer/core.py b/src/logreducer/core.py index 243a86f..2d59d80 100644 --- a/src/logreducer/core.py +++ b/src/logreducer/core.py @@ -348,7 +348,7 @@ def _process_pattern_mode(self, source: Source) -> list[str]: # Collect the examples of quiet patterns (the extractor has already applied max_patterns to # them). Severe lines come from the severe shapes that reduce() adds, one per shape. - result = [] + result: list[str] = [] for pattern in patterns: result.extend(line for line in pattern.examples if not is_severe(line)) diff --git a/src/logreducer/patterns.py b/src/logreducer/patterns.py index c49b2d9..09c3958 100644 --- a/src/logreducer/patterns.py +++ b/src/logreducer/patterns.py @@ -231,7 +231,7 @@ def _priority(pattern: LogPattern) -> float: class PatternExtractor: """Extract patterns using the online template miner (see template_mining).""" - def __init__(self, config: "BigDialConfig") -> None: + def __init__(self, config: BigDialConfig) -> None: self.config = config self.setup_drain3() From 8e80afbe72fc9f7ae2aeebcbac04c6a7941fb7dc Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 19:29:22 +1100 Subject: [PATCH 20/21] fix: read seeded MySQL sample columns on first read A seeded MySQL SQLSource constructor opened a connection for query column names. Construction failed if the server was unreachable. An invalid fraction raised a connection error instead of a ValueError. The constructor now only validates the request. The first pass reads columns through its query connection and saves the built SQL for later passes. --- src/logreducer/sql.py | 34 +++++++++++++++++++++------------- tests/unit/test_sql_source.py | 17 +++++++++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/logreducer/sql.py b/src/logreducer/sql.py index c62cec0..8cd1d1e 100644 --- a/src/logreducer/sql.py +++ b/src/logreducer/sql.py @@ -106,28 +106,34 @@ def __init__( # on SQLite) fails at construction, not deep inside iteration. self._sample_setup: str | None = None self._sample_sql: str | None = None + # A seeded MySQL sample hashes the query's columns, read on the first pass's connection. + self._needs_columns = False if sample is not None: from .sampling import build_sample_sql dialect = self._engine.dialect.name - columns = None - if sample_seed is not None and dialect in ("mysql", "mariadb"): - columns = self._quoted_columns(query, params) - self._sample_setup, self._sample_sql = build_sample_sql(dialect, query, sample, sample_seed, columns) - - def _quoted_columns(self, query: str, params: dict[str, Any] | None) -> list[str]: - """Return the quoted column names of ``query``, read with a zero-row run of it.""" + self._needs_columns = sample_seed is not None and dialect in ("mysql", "mariadb") + placeholder = ["NULL"] if self._needs_columns else None + setup, sql = build_sample_sql(dialect, query, sample, sample_seed, placeholder) + if not self._needs_columns: + self._sample_setup, self._sample_sql = setup, sql + + def _build_column_sample(self, conn: Any, sample: float) -> None: + """Build the seeded MySQL sample from the query's quoted columns, read with a zero-row run of it.""" from sqlalchemy import text - from .sampling import wrap_subquery + from .sampling import build_sample_sql, wrap_subquery # Risk accepted: the query is the caller's own SELECT, which this source exists to run. # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text - probe = text(f"SELECT * FROM ({wrap_subquery(query)}) AS _lr_columns LIMIT 0") - with self._engine.connect() as conn: - names = list(conn.execute(probe, params or {}).keys()) + probe = text(f"SELECT * FROM ({wrap_subquery(self.query)}) AS _lr_columns LIMIT 0") + names = list(conn.execute(probe, self.params or {}).keys()) quote = self._engine.dialect.identifier_preparer.quote - return [f"_lr_sample.{quote(name)}" for name in names] + columns = [f"_lr_sample.{quote(name)}" for name in names] + self._sample_setup, self._sample_sql = build_sample_sql( + self._engine.dialect.name, self.query, sample, self.sample_seed, columns + ) + self._needs_columns = False @classmethod def from_table( @@ -180,8 +186,10 @@ def __iter__(self) -> Iterator[str]: from .sources import rows_to_lines - stmt = text(self._sample_sql if self._sample_sql is not None else self.query) with self._engine.connect() as conn: + if self._needs_columns and self.sample is not None: + self._build_column_sample(conn, self.sample) + stmt = text(self._sample_sql if self._sample_sql is not None else self.query) # A deterministic sample (PostgreSQL) needs its per-connection seed # set before the query, on the same connection. if self._sample_setup is not None: diff --git a/tests/unit/test_sql_source.py b/tests/unit/test_sql_source.py index 9f6dd1a..18f6de3 100644 --- a/tests/unit/test_sql_source.py +++ b/tests/unit/test_sql_source.py @@ -104,6 +104,23 @@ def test_sqlsource_from_table_seed_on_sqlite_raises(sqlite_url): SQLSource.from_table(sqlite_url, "logs", "line", sample=0.3, sample_seed=1) +@pytest.mark.parametrize(("sample", "error"), [(0.2, None), (1.5, ValueError)]) +def test_seeded_mysql_source_connects_only_when_read(sample, error): + """Construction validates the request but opens no connection, so an unreachable server fails on the read.""" + pytest.importorskip("pymysql") + from sqlalchemy.exc import OperationalError + + unreachable = "mysql+pymysql://user:pw@127.0.0.1:1/logs" # nothing listens on port 1 + if error is not None: + with pytest.raises(error): + SQLSource(unreachable, "SELECT line FROM logs", sample=sample, sample_seed=42) + return + + source = SQLSource(unreachable, "SELECT line FROM logs", sample=sample, sample_seed=42) + with pytest.raises(OperationalError): + list(source) + + def test_reduce_to_target_over_sqlsource(sqlite_url): # 4 distinct patterns in the table; ask for 3 - reached via sampled batches. reducer = LogReducer(level="standard", mode="pattern") From e78fa85f7817e69d148e3742def5a7dc6b442853 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 1 Nov 2026 19:29:49 +1100 Subject: [PATCH 21/21] docs: list the architecture invariants without bold leads The eight invariants in docs/architecture.md were a run of bold-lead items. They are now plain numbered lines, one per invariant, unwrapped. Invariant 8 now says the 70% RAM clamp covers LogReducer arguments as well as the config. --- docs/architecture.md | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6d816ee..4a9461b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,24 +131,14 @@ Three seams, none of which needs the library to know anything about the host. ## Invariants -1. **`__iter__` returns a fresh iterator.** Checked at the entry point, not - documented and hoped for. -2. **The core imports no IO library.** An adapter's dependency is an extra, and - the core never imports the adapter modules. -3. **Analysis state is per run.** `_reset_components()` rebuilds the dedup - seen-set, both Drain3 miners and the fuzzy LSH, so one `LogReducer` is - reusable across calls and hybrid's two passes cannot poison each other. - Without it every line reads as already-seen and the result is empty. -4. **An unknown config keyword raises.** A silently dropped override is tuning - that quietly does nothing, which is worse than a failure. -5. **The row-to-line convention is shared.** The database adapters take the FIRST - column, skip NULLs and skip blank lines, matching `FileSource`, so the same - data reduces identically whichever source carried it. -6. **Every sample is seeded.** See above. -7. **Config enums serialise as their `.value`**, so metadata survives - `json.dumps` and no `OutputFormat.LINE`-style repr leaks into output. -8. **A memory ceiling above 70% of available RAM is clamped with a warning** - rather than accepted. +1. `__iter__` returns a fresh iterator, checked at the entry point rather than documented and hoped for. +2. The core imports no IO library. An adapter's dependency is an extra, and the core never imports the adapter modules. +3. Analysis state is per run. `_reset_components()` rebuilds the dedup seen-set, both Drain3 miners and the fuzzy LSH, so one `LogReducer` is reusable across calls and hybrid's two passes cannot poison each other. Without it every line reads as already-seen and the result is empty. +4. An unknown config keyword raises. A silently dropped override is tuning that quietly does nothing, which is worse than a failure. +5. The row-to-line convention is shared. The database adapters take the FIRST column, skip NULLs and skip blank lines, matching `FileSource`, so the same data reduces identically whichever source carried it. +6. Every sample is seeded. See above. +7. Config enums serialise as their `.value`, so metadata survives `json.dumps` and no `OutputFormat.LINE`-style repr leaks into output. +8. A memory ceiling above 70% of available RAM is clamped with a warning rather than accepted, whether it comes from the config or a `LogReducer` argument. ### Where the numbers are approximate