logreducer 3.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
logreducer/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """LogReducer - reduce large volumes of log lines to a representative sample.
2
+
3
+ A streaming reduction engine (dedup -> pattern mining -> anomaly/temporal
4
+ analysis) with an IO-agnostic core: any re-iterable stream of str lines is a
5
+ valid Source. Ships as both a library and a `logreducer` CLI.
6
+
7
+ Copyright 2026 HYPERI PTY LIMITED.
8
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ try:
14
+ __version__ = version("logreducer")
15
+ except PackageNotFoundError: # running from a source tree that is not installed
16
+ __version__ = "0.0.0+unknown"
17
+
18
+ from .config import BigDialConfig, OutputFormat, ProcessingLevel, ProcessingMode
19
+ from .core import LogReducer
20
+ from .logging_config import setup_logging
21
+ from .sampling import SamplingNotSupported
22
+ from .sinks import FileSink, Sink
23
+ from .sources import FileSource, Source
24
+ from .target import reduce_to_target
25
+
26
+ __all__ = [
27
+ "BigDialConfig",
28
+ "FileSink",
29
+ "FileSource",
30
+ "LogReducer",
31
+ "OutputFormat",
32
+ "ProcessingLevel",
33
+ "ProcessingMode",
34
+ "SamplingNotSupported",
35
+ "Sink",
36
+ "Source",
37
+ "__version__",
38
+ "reduce_to_target",
39
+ "setup_logging",
40
+ ]
logreducer/anomaly.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Anomaly detection utilities
3
+ """
4
+
5
+ from loguru import logger
6
+
7
+ # Try optional imports
8
+ try:
9
+ from sklearn.ensemble import IsolationForest
10
+ from sklearn.feature_extraction.text import TfidfVectorizer
11
+
12
+ SKLEARN_AVAILABLE = True
13
+ except ImportError:
14
+ SKLEARN_AVAILABLE = False
15
+
16
+
17
+ class AnomalyDetector:
18
+ """Detect anomalous log lines"""
19
+
20
+ def __init__(self, contamination: float = 0.1):
21
+ self.contamination = contamination
22
+ self.enabled = SKLEARN_AVAILABLE
23
+
24
+ if not self.enabled:
25
+ logger.warning("scikit-learn not available, anomaly detection disabled")
26
+
27
+ def detect_anomalies(self, lines: list[str]) -> tuple[list[str], list[str]]:
28
+ """
29
+ Separate anomalous and normal lines
30
+
31
+ Returns:
32
+ (anomalous_lines, normal_lines)
33
+ """
34
+ if not self.enabled or len(lines) < 10:
35
+ return [], lines
36
+
37
+ # Vectorize
38
+ vectorizer = TfidfVectorizer(
39
+ max_features=min(1000, len(lines) // 10),
40
+ ngram_range=(1, 3),
41
+ max_df=0.9,
42
+ min_df=2 if len(lines) > 100 else 1,
43
+ )
44
+
45
+ try:
46
+ # Keep the TF-IDF matrix SPARSE - IsolationForest accepts sparse
47
+ # input directly. Densifying (.toarray()) would allocate
48
+ # n_lines x n_features x 8 bytes, which dwarfs the sparse matrix and
49
+ # can blow the memory budget on a large unique-line set.
50
+ X = vectorizer.fit_transform(lines)
51
+ except ValueError:
52
+ # Empty vocabulary (every term filtered by min_df/max_df) - nothing
53
+ # to score, so treat all lines as normal rather than failing.
54
+ return [], lines
55
+
56
+ iso_forest = IsolationForest(contamination=self.contamination, random_state=42, n_estimators=100)
57
+ labels = iso_forest.fit_predict(X)
58
+ scores = iso_forest.score_samples(X)
59
+
60
+ # One pass: split normal vs anomalous, collecting scores for the latter.
61
+ normal: list[str] = []
62
+ anomaly_with_scores: list[tuple[str, float]] = []
63
+ for line, label, score in zip(lines, labels, scores, strict=False):
64
+ if label == -1:
65
+ anomaly_with_scores.append((line, score))
66
+ else:
67
+ normal.append(line)
68
+
69
+ # Most anomalous first (IsolationForest scores: lower = more anomalous).
70
+ anomaly_with_scores.sort(key=lambda pair: pair[1])
71
+ anomalous = [line for line, _ in anomaly_with_scores]
72
+
73
+ logger.info(f"Found {len(anomalous)} anomalies out of {len(lines)} lines")
74
+
75
+ return anomalous, normal
logreducer/cli.py ADDED
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """LogReducer command-line interface.
3
+
4
+ A single command that reduces a log source to a representative sample. The
5
+ source is one of:
6
+
7
+ * a file - ``logreducer app.log``
8
+ * a SQL or ClickHouse query - ``logreducer --dsn postgresql://... --query '...'``
9
+ * a Kafka topic - ``logreducer --dsn kafka://broker:9092 --topic logs --group g``
10
+
11
+ The DB and Kafka sources need the matching optional extra installed
12
+ (``logreducer[sql]`` / ``[clickhouse]`` / ``[kafka]``); a plain file needs
13
+ nothing. Built on typer.
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from typing import Any
19
+
20
+ import typer
21
+
22
+ from . import __version__
23
+ from .core import LogReducer
24
+ from .logging_config import setup_logging
25
+ from .sources import Source
26
+
27
+
28
+ def _err(message: str) -> None:
29
+ """Write an error line to stderr."""
30
+ print(message, file=sys.stderr)
31
+
32
+
33
+ def _version_callback(value: bool) -> None:
34
+ """Eager --version handler: print version and exit."""
35
+ if value:
36
+ print(f"logreducer version {__version__}")
37
+ raise typer.Exit(0)
38
+
39
+
40
+ def _build_dsn_source(
41
+ dsn: str,
42
+ query: str | None,
43
+ topic: str | None,
44
+ group: str | None,
45
+ *,
46
+ sample: float | None = None,
47
+ sample_seed: int | None = None,
48
+ ) -> Source:
49
+ """Dispatch a ``--dsn`` to the matching source adapter by URL scheme.
50
+
51
+ ``clickhouse://`` -> ClickHouseSource, ``kafka://`` -> KafkaSource, and any
52
+ other scheme (postgresql, mysql, sqlite, ...) -> SQLSource via SQLAlchemy.
53
+ Adapter imports are lazy so a plain-file run never needs the extras.
54
+ ``sample`` (and ``sample_seed`` for SQL) apply to the DB sources; Kafka has
55
+ no sampling and rejects it.
56
+ """
57
+ scheme = dsn.split("://", 1)[0].lower() if "://" in dsn else ""
58
+
59
+ if scheme == "clickhouse":
60
+ if not query:
61
+ raise typer.BadParameter("--query is required for a clickhouse:// source")
62
+ from .clickhouse import ClickHouseSource
63
+
64
+ return ClickHouseSource(dsn, query, sample=sample)
65
+
66
+ if scheme == "kafka":
67
+ if not (topic and group):
68
+ raise typer.BadParameter("--topic and --group are required for a kafka:// source")
69
+ if sample is not None:
70
+ raise typer.BadParameter("--sample is not supported for a kafka:// source")
71
+ from .kafka import KafkaSource
72
+
73
+ brokers = dsn.split("://", 1)[1]
74
+ return KafkaSource(brokers, group, topic)
75
+
76
+ # Everything else is standard SQL reached through SQLAlchemy.
77
+ if not query:
78
+ raise typer.BadParameter("--query is required for a SQL --dsn source")
79
+ from .sql import SQLSource
80
+
81
+ return SQLSource(dsn, query, sample=sample, sample_seed=sample_seed)
82
+
83
+
84
+ def _reduce(
85
+ input_file: str | None = typer.Argument(None, help="Log file to reduce (omit when using --dsn)"),
86
+ output: str | None = typer.Option(None, "--output", "-o", help="Output file (default: stdout)"),
87
+ output_format: str = typer.Option("line", "--format", help="Output format: line, json, jsonl"),
88
+ pretty_json: bool = typer.Option(False, "--pretty-json", help="Pretty-print JSON output"),
89
+ level: str = typer.Option("standard", "--level", "-l", help="Processing level: standard, enhanced, maximum"),
90
+ mode: str = typer.Option("pattern", "--mode", "-m", help="Processing mode: pattern, anomaly, temporal, hybrid"),
91
+ dsn: str | None = typer.Option(None, "--dsn", help="Source DSN: postgresql://, clickhouse://, kafka://, ..."),
92
+ query: str | None = typer.Option(None, "--query", help="SQL SELECT (first column is the log line) for --dsn"),
93
+ topic: str | None = typer.Option(None, "--topic", help="Kafka topic (with a kafka:// --dsn)"),
94
+ group: str | None = typer.Option(None, "--group", help="Kafka consumer group (with a kafka:// --dsn)"),
95
+ max_memory: float | None = typer.Option(None, "--max-memory", help="Maximum memory usage in GB"),
96
+ max_patterns: int | None = typer.Option(None, "--max-patterns", help="Maximum number of patterns to extract"),
97
+ log: bool = typer.Option(False, "--log", help="Enable processing logs"),
98
+ log_file: str | None = typer.Option(None, "--log-file", help="Log file path"),
99
+ log_level: str = typer.Option("INFO", "--log-level", help="Logging level: DEBUG, INFO, WARNING, ERROR"),
100
+ estimate: bool = typer.Option(False, "--estimate", help="Estimate processing requirements and exit (file only)"),
101
+ metadata: bool = typer.Option(False, "--metadata", help="Include detailed metadata in output"),
102
+ stats: bool = typer.Option(False, "--stats", help="Print processing statistics to stderr"),
103
+ sample: float | None = typer.Option(
104
+ None, "--sample", help="Sample this fraction (0-1) of source rows (SQL/ClickHouse)"
105
+ ),
106
+ sample_seed: int | None = typer.Option(
107
+ None, "--sample-seed", help="Seed for a reproducible --sample (SQL: PostgreSQL/MySQL)"
108
+ ),
109
+ target_rows: int | None = typer.Option(
110
+ None, "--target-rows", help="Collect this many reduced lines via repeated sampled batches"
111
+ ),
112
+ max_fetches: int = typer.Option(50, "--max-fetches", help="Max sampled batches to pull for --target-rows"),
113
+ max_batch_memory: float | None = typer.Option(
114
+ None, "--max-batch-memory", help="Per-batch memory budget in GB for --target-rows"
115
+ ),
116
+ version: bool = typer.Option(
117
+ False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit"
118
+ ),
119
+ ) -> None:
120
+ """Reduce a log source to a representative sample."""
121
+ # Build reducer config overrides, dropping unset values.
122
+ overrides: dict[str, Any] = {
123
+ "max_memory_gb": max_memory,
124
+ "max_patterns": max_patterns,
125
+ "enable_logging": log or None,
126
+ "log_file": log_file,
127
+ "log_level": log_level,
128
+ "output_format": output_format,
129
+ "pretty_json": pretty_json or None,
130
+ }
131
+ kwargs = {k: v for k, v in overrides.items() if v is not None}
132
+
133
+ try:
134
+ reducer = LogReducer(level=level, mode=mode, **kwargs)
135
+ except ValueError as exc:
136
+ _err(f"Error: {exc}")
137
+ raise typer.Exit(1) from exc
138
+
139
+ # The reducer configures only a file sink; when --log is set the CLI also
140
+ # wants progress on the console.
141
+ if log:
142
+ setup_logging(enable=True, console=True, log_file=log_file, log_level=log_level)
143
+
144
+ # --estimate is a file-only, pre-flight sizing step.
145
+ if estimate:
146
+ if not input_file:
147
+ _err("Error: --estimate requires an input file")
148
+ raise typer.Exit(1)
149
+ _run_estimate(reducer, input_file)
150
+ return
151
+
152
+ # --target-rows switches to the reduce-to-target orchestrator: pull sampled
153
+ # batches and accumulate reduced lines until the target is met.
154
+ if target_rows is not None:
155
+ _run_target(
156
+ reducer,
157
+ dsn=dsn,
158
+ query=query,
159
+ topic=topic,
160
+ group=group,
161
+ input_file=input_file,
162
+ output=output,
163
+ output_format=output_format,
164
+ pretty_json=pretty_json,
165
+ stats=stats,
166
+ sample=sample,
167
+ sample_seed=sample_seed,
168
+ target_rows=target_rows,
169
+ max_fetches=max_fetches,
170
+ max_batch_memory=max_batch_memory,
171
+ )
172
+ return
173
+
174
+ try:
175
+ if dsn:
176
+ source = _build_dsn_source(dsn, query, topic, group, sample=sample, sample_seed=sample_seed)
177
+ try:
178
+ result = reducer.reduce(source, output_file=output, return_metadata=metadata)
179
+ finally:
180
+ close = getattr(source, "close", None)
181
+ if callable(close):
182
+ close()
183
+ elif input_file:
184
+ if sample is not None or sample_seed is not None:
185
+ # Same treatment as the kafka:// rejection: fail loudly rather
186
+ # than silently ignore a flag that only applies to DB sources.
187
+ raise typer.BadParameter("--sample/--sample-seed apply to SQL/ClickHouse sources, not files")
188
+ result = reducer.process_file(input_file, output, return_metadata=metadata)
189
+ else:
190
+ _err("Error: provide a log file, or --dsn with --query (SQL/ClickHouse) or --topic/--group (Kafka)")
191
+ raise typer.Exit(1)
192
+ except (typer.Exit, typer.BadParameter):
193
+ raise # our control-flow exit / typer usage errors - let typer format them
194
+ except KeyboardInterrupt:
195
+ _err("Processing interrupted by user")
196
+ raise typer.Exit(1) from None
197
+ except Exception as exc:
198
+ # File-not-found, a missing optional extra (ImportError), or an adapter
199
+ # failure (bad DSN, connection refused, bad SQL, Kafka error) - report a
200
+ # clean one-line error instead of dumping a Python traceback.
201
+ _err(f"Error: {exc}")
202
+ raise typer.Exit(1) from exc
203
+
204
+ _emit_result(result, output, output_format, pretty_json, metadata)
205
+
206
+ if stats:
207
+ _print_stats(reducer.stats)
208
+
209
+
210
+ def _run_estimate(reducer: LogReducer, input_file: str) -> None:
211
+ """Print a pre-flight processing estimate for a file.
212
+
213
+ The estimate is the command's product, so it goes to stdout via ``print``
214
+ (not the library logger, which is disabled unless --log is passed).
215
+ """
216
+ try:
217
+ est = reducer.estimate_processing(input_file)
218
+ except OSError as exc:
219
+ _err(f"Error estimating processing: {exc}")
220
+ raise typer.Exit(1) from exc
221
+
222
+ print("Processing Estimation")
223
+ print("=" * 50)
224
+ print(f"File size: {est['file_size_gb']:.2f} GB")
225
+ print(f"Estimated memory: {est['memory_required_gb']:.2f} GB")
226
+ print(f"Processing strategy: {est['strategy']}")
227
+ print(f"Estimated time: {est['estimated_time_seconds']:.0f} seconds")
228
+ print(f"Will sample data: {'Yes' if est['will_sample'] else 'No'}")
229
+ print(f"Expected output lines: ~{est['estimated_output_lines']:,}")
230
+ if est["memory_required_gb"] > 8.0:
231
+ _err("Warning: large memory requirements detected; consider --max-memory")
232
+
233
+
234
+ def _run_target(
235
+ reducer: LogReducer,
236
+ *,
237
+ dsn: str | None,
238
+ query: str | None,
239
+ topic: str | None,
240
+ group: str | None,
241
+ input_file: str | None,
242
+ output: str | None,
243
+ output_format: str,
244
+ pretty_json: bool,
245
+ stats: bool,
246
+ sample: float | None,
247
+ sample_seed: int | None,
248
+ target_rows: int,
249
+ max_fetches: int,
250
+ max_batch_memory: float | None,
251
+ ) -> None:
252
+ """Collect target_rows reduced lines by pulling repeated sampled batches."""
253
+ from .target import reduce_to_target
254
+
255
+ try:
256
+ if dsn:
257
+ source: Source = _build_dsn_source(dsn, query, topic, group, sample=sample, sample_seed=sample_seed)
258
+ elif input_file:
259
+ from .sources import FileSource
260
+
261
+ source = FileSource(input_file, max_memory_gb=reducer.config.max_memory_gb)
262
+ else:
263
+ _err("Error: --target-rows needs a log file, or --dsn with --query (SQL/ClickHouse)")
264
+ raise typer.Exit(1)
265
+
266
+ try:
267
+ outcome = reduce_to_target(
268
+ source,
269
+ reducer,
270
+ target_rows=target_rows,
271
+ max_fetches=max_fetches,
272
+ max_batch_memory_gb=max_batch_memory,
273
+ seed=sample_seed,
274
+ )
275
+ finally:
276
+ close = getattr(source, "close", None)
277
+ if callable(close):
278
+ close()
279
+ except (typer.Exit, typer.BadParameter):
280
+ raise
281
+ except KeyboardInterrupt:
282
+ _err("Processing interrupted by user")
283
+ raise typer.Exit(1) from None
284
+ except Exception as exc:
285
+ _err(f"Error: {exc}")
286
+ raise typer.Exit(1) from exc
287
+
288
+ _emit_lines(outcome["lines"], output, output_format, pretty_json)
289
+ if stats:
290
+ s = outcome["stats"]
291
+ print(
292
+ f"\nCollected {s['collected']}/{s['target_rows']} lines in {s['fetches']} fetch(es) "
293
+ f"(stopped: {s['stop_reason']})",
294
+ file=sys.stderr,
295
+ )
296
+
297
+
298
+ def _emit_lines(lines: list[str], output: str | None, output_format: str, pretty_json: bool) -> None:
299
+ """Write reduced lines to a file (via FileSink) or print them to stdout."""
300
+ if output:
301
+ from .sinks import FileSink
302
+
303
+ FileSink(output, output_format=output_format).write(lines)
304
+ return
305
+ if output_format == "json":
306
+ print(json.dumps({"lines": lines}, indent=2 if pretty_json else None))
307
+ elif output_format == "jsonl":
308
+ for line in lines:
309
+ print(json.dumps({"line": line}))
310
+ else:
311
+ for line in lines:
312
+ print(line)
313
+
314
+
315
+ def _emit_result(
316
+ result: list[str] | dict,
317
+ output: str | None,
318
+ output_format: str,
319
+ pretty_json: bool,
320
+ metadata: bool,
321
+ ) -> None:
322
+ """Print the reduced result to stdout when not writing to a file."""
323
+ if output:
324
+ return # Already written by the reducer.
325
+
326
+ if metadata and isinstance(result, dict):
327
+ if output_format == "json":
328
+ print(json.dumps(result, indent=2 if pretty_json else None))
329
+ else:
330
+ for line in result["lines"]:
331
+ print(line)
332
+ elif isinstance(result, list):
333
+ # Honour --format on stdout the same way the --target-rows path does.
334
+ _emit_lines(result, None, output_format, pretty_json)
335
+
336
+
337
+ def _print_stats(stats: dict) -> None:
338
+ """Print a processing summary to stderr (size/rate omitted for non-files)."""
339
+ print("\nProcessing completed:", file=sys.stderr)
340
+ input_size_mb = stats.get("input_size_mb")
341
+ if input_size_mb is not None:
342
+ print(f" Input: {stats['input_lines']:,} lines ({input_size_mb:.1f} MB)", file=sys.stderr)
343
+ else:
344
+ print(f" Input: {stats['input_lines']:,} lines", file=sys.stderr)
345
+ print(f" Output: {stats['output_lines']:,} lines", file=sys.stderr)
346
+ print(f" Reduction: {stats['reduction_percent']:.1f}%", file=sys.stderr)
347
+ print(f" Time: {stats['processing_time_seconds']:.2f}s", file=sys.stderr)
348
+ rate = stats.get("processing_rate_mb_per_sec")
349
+ if rate is not None:
350
+ print(f" Rate: {rate:.1f} MB/sec", file=sys.stderr)
351
+
352
+
353
+ def main() -> None:
354
+ """Console-script entry point."""
355
+ typer.run(_reduce)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
@@ -0,0 +1,171 @@
1
+ """ClickHouse input source for logreducer (optional ``clickhouse`` extra).
2
+
3
+ Streams log lines out of ClickHouse using the official ``clickhouse-connect``
4
+ driver's native block streaming (``query_row_block_stream``). ClickHouse returns
5
+ results in blocks; this iterates block by block and row by row, so memory stays
6
+ flat no matter how large the result is.
7
+
8
+ ClickHouse gets its own adapter rather than going through SQLAlchemy on purpose:
9
+ ClickHouse's SQLAlchemy dialect buffers the whole result client-side, which
10
+ defeats constant-memory streaming. The native driver's block interface is the
11
+ only path that streams. Everything else mirrors SQLSource - re-iterable (each
12
+ pass re-runs the query), first column is the log line, NULL/blank skipped.
13
+
14
+ Row -> line convention: the query selects the log line as its **first column**,
15
+ e.g. ``SELECT message FROM logs WHERE ...``.
16
+
17
+ Install: ``pip install 'logreducer[clickhouse]'``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections.abc import Iterator
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ if TYPE_CHECKING:
27
+ from clickhouse_connect.driver.client import Client
28
+
29
+ _INSTALL_HINT = "logreducer's ClickHouse source needs clickhouse-connect. Install the extra:\n pip install 'logreducer[clickhouse]'"
30
+
31
+
32
+ class ClickHouseSource:
33
+ """A re-iterable stream of log lines from a ClickHouse query.
34
+
35
+ Each iteration opens a block stream and re-runs the query, so the reducer's
36
+ multi-pass modes work. The first column of each row is the log line; NULLs
37
+ and blank lines are skipped, matching FileSource.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ client_or_dsn: Client | str,
43
+ query: str,
44
+ *,
45
+ parameters: dict[str, Any] | None = None,
46
+ settings: dict[str, Any] | None = None,
47
+ sample: float | None = None,
48
+ ) -> None:
49
+ """Build a ClickHouse source.
50
+
51
+ Args:
52
+ client_or_dsn: A ``clickhouse-connect`` Client (borrowed, not
53
+ closed) or a ``clickhouse://user:pass@host:port/db`` DSN string
54
+ (a client is created and owned here).
55
+ query: A SQL SELECT whose first column is the log line.
56
+ parameters: Optional query parameters (server-side binding).
57
+ settings: Optional ClickHouse settings for the query.
58
+ sample: Optional fraction in (0, 1] - appends ClickHouse's native
59
+ ``SAMPLE`` clause. ClickHouse sampling is deterministic (no seed
60
+ needed), so it is safe for the reducer's multi-pass modes. It
61
+ REQUIRES the queried table to declare ``SAMPLE BY`` in its
62
+ ORDER BY, and the query to be a plain ``SELECT ... FROM table``
63
+ (SAMPLE goes after the table, before any WHERE) - otherwise put
64
+ the SAMPLE clause in your own query instead.
65
+ """
66
+ try:
67
+ import clickhouse_connect
68
+ from clickhouse_connect.driver.client import Client as _Client
69
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
70
+ raise ImportError(_INSTALL_HINT) from exc
71
+
72
+ if isinstance(client_or_dsn, str):
73
+ self._client = clickhouse_connect.get_client(dsn=client_or_dsn)
74
+ self._owns_client = True
75
+ elif isinstance(client_or_dsn, _Client):
76
+ self._client = client_or_dsn
77
+ self._owns_client = False
78
+ else:
79
+ raise TypeError(
80
+ f"client_or_dsn must be a clickhouse-connect Client or a DSN string, got {type(client_or_dsn).__name__}"
81
+ )
82
+
83
+ if sample is not None:
84
+ if not (0.0 < sample <= 1.0):
85
+ raise ValueError(f"sample fraction must be in (0, 1], got {sample!r}")
86
+ # SAMPLE must sit after the table, BEFORE any WHERE/GROUP/ORDER -
87
+ # appending it to a query with those clauses builds invalid SQL, so
88
+ # fail fast and point at the alternatives.
89
+ if re.search(r"\b(WHERE|GROUP\s+BY|ORDER\s+BY|LIMIT)\b", query, re.IGNORECASE):
90
+ raise ValueError(
91
+ "sample= only supports a plain 'SELECT ... FROM table' query "
92
+ "(ClickHouse SAMPLE goes before WHERE/GROUP/ORDER); use "
93
+ "from_table(..., where=...) or put SAMPLE in your own query"
94
+ )
95
+
96
+ self.query = query
97
+ self.parameters = parameters
98
+ self.settings = settings
99
+ self.sample = sample
100
+ self._query = f"{query} SAMPLE {float(sample)!r}" if sample is not None else query
101
+
102
+ @classmethod
103
+ def from_table(
104
+ cls,
105
+ client_or_dsn: Client | str,
106
+ table: str,
107
+ column: str,
108
+ *,
109
+ sample: float,
110
+ where: str | None = None,
111
+ parameters: dict[str, Any] | None = None,
112
+ settings: dict[str, Any] | None = None,
113
+ ) -> ClickHouseSource:
114
+ """Sample a fraction of a table with ClickHouse's native ``SAMPLE`` clause.
115
+
116
+ Builds ``SELECT column FROM table SAMPLE k [WHERE ...]`` with the clause
117
+ in the correct position (after the table, before WHERE). Deterministic by
118
+ construction. Requires the table to declare a ``SAMPLE BY`` key in its
119
+ ORDER BY; otherwise ClickHouse rejects the query.
120
+ """
121
+ if not (0.0 < sample <= 1.0):
122
+ raise ValueError(f"sample fraction must be in (0, 1], got {sample!r}")
123
+ # Backtick-quote identifiers, doubling any embedded backtick so a name
124
+ # can never break out of its quoting.
125
+ tbl = "`" + table.replace("`", "``") + "`"
126
+ col = "`" + column.replace("`", "``") + "`"
127
+ where_sql = f" WHERE {where}" if where else ""
128
+ query = f"SELECT {col} FROM {tbl} SAMPLE {float(sample)!r}{where_sql}"
129
+ return cls(client_or_dsn, query, parameters=parameters, settings=settings)
130
+
131
+ def __iter__(self) -> Iterator[str]:
132
+ from .sources import rows_to_lines
133
+
134
+ with self._client.query_row_block_stream(
135
+ self._query, parameters=self.parameters, settings=self.settings
136
+ ) as stream:
137
+ for block in stream:
138
+ yield from rows_to_lines(block)
139
+
140
+ def sample_batch(self, n: int) -> list[str]:
141
+ """Return one fresh random batch of up to ``n`` log lines.
142
+
143
+ Runs ``... ORDER BY rand() LIMIT n`` over the query, so successive calls
144
+ draw different rows (with-replacement) - the primitive the
145
+ ``reduce_to_target`` loop pulls from. A one-off list, not a stream.
146
+ Deliberately samples the FULL query population, ignoring any constructor
147
+ ``sample=`` fraction - the target loop wants fresh draws from everything.
148
+ """
149
+ from .sampling import build_sample_batch_sql
150
+ from .sources import rows_to_lines
151
+
152
+ sql = build_sample_batch_sql("clickhouse", self.query, n)
153
+ out: list[str] = []
154
+ with self._client.query_row_block_stream(sql, parameters=self.parameters, settings=self.settings) as stream:
155
+ for block in stream:
156
+ out.extend(rows_to_lines(block))
157
+ return out
158
+
159
+ def close(self) -> None:
160
+ """Close the client, but only if this source created it."""
161
+ if self._owns_client:
162
+ self._client.close()
163
+
164
+ def __enter__(self) -> ClickHouseSource:
165
+ return self
166
+
167
+ def __exit__(self, *exc: object) -> None:
168
+ self.close()
169
+
170
+ def __repr__(self) -> str:
171
+ return f"ClickHouseSource(query={self.query!r})"