smartpipe-cli 1.3.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.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
smartpipe/verbs/split.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""The ``split`` verb (D26 layer 3): oversized items → budget-sized chunk items.
|
|
2
|
+
|
|
3
|
+
Zero model calls. One 300-page PDF becomes N records of ``{"text", "source"}``
|
|
4
|
+
with provenance (``report.pdf §3/12``), each small enough for whatever verb
|
|
5
|
+
comes next. The taught pipeline: ``smartpipe split --in big.pdf | smartpipe map … |
|
|
6
|
+
smartpipe reduce …``. Chunks concatenate back to the original text exactly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar
|
|
14
|
+
|
|
15
|
+
from smartpipe.core.errors import ExitCode, ItemError, UsageFault
|
|
16
|
+
from smartpipe.engine.chunking import split_text
|
|
17
|
+
from smartpipe.engine.units import SplitBy, parse_by
|
|
18
|
+
from smartpipe.io import diagnostics, readers
|
|
19
|
+
from smartpipe.io.inputs import STDIN
|
|
20
|
+
from smartpipe.io.items import describe_source
|
|
21
|
+
from smartpipe.models.base import AudioData, ImageData, VideoData
|
|
22
|
+
from smartpipe.verbs.common import ensure_text, interrupted_exit_code, outcome_exit_code
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pathlib import Path as PathType
|
|
26
|
+
from typing import TextIO
|
|
27
|
+
|
|
28
|
+
from smartpipe.io.inputs import InputSpec
|
|
29
|
+
from smartpipe.io.items import Item
|
|
30
|
+
from smartpipe.io.writers import OutputFormat, ResultWriter
|
|
31
|
+
|
|
32
|
+
__all__ = ["SplitContext", "SplitRequest", "run_split"]
|
|
33
|
+
|
|
34
|
+
_M = TypeVar("_M", AudioData, VideoData)
|
|
35
|
+
|
|
36
|
+
_DEFAULT_BUDGET_TOKENS = 2_000 # comfortable for every wired window, ~8k chars
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class SplitRequest:
|
|
41
|
+
max_tokens_flag: int | None = None
|
|
42
|
+
by_flag: str | None = None # --by UNIT[:N] (D26 rich units)
|
|
43
|
+
media: bool = False # --media: embedded images become items (D29)
|
|
44
|
+
input: InputSpec = STDIN
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SplitContext(Protocol):
|
|
48
|
+
"""The slice of the container ``split`` needs (no model — just the writer)."""
|
|
49
|
+
|
|
50
|
+
def writer(
|
|
51
|
+
self,
|
|
52
|
+
output_flag: OutputFormat,
|
|
53
|
+
*,
|
|
54
|
+
structured: bool,
|
|
55
|
+
stdout: TextIO,
|
|
56
|
+
fields: tuple[str, ...] | None = None,
|
|
57
|
+
) -> ResultWriter: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_by(request: SplitRequest) -> SplitBy:
|
|
61
|
+
if request.by_flag is not None and request.max_tokens_flag is not None:
|
|
62
|
+
raise UsageFault("--by and --max-tokens both set the unit — use one")
|
|
63
|
+
if request.by_flag is not None:
|
|
64
|
+
return parse_by(request.by_flag)
|
|
65
|
+
if request.max_tokens_flag is not None:
|
|
66
|
+
if request.max_tokens_flag < 1:
|
|
67
|
+
raise UsageFault("--max-tokens must be at least 1")
|
|
68
|
+
return SplitBy("tokens", request.max_tokens_flag)
|
|
69
|
+
return SplitBy("tokens", _DEFAULT_BUDGET_TOKENS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_chunks(writer: ResultWriter, item: Item, by: SplitBy) -> None:
|
|
73
|
+
origin = describe_source(item.source) # "report.pdf" / "line 12"
|
|
74
|
+
if by.unit in ("minutes", "seconds") and (video := _single(item, VideoData)) is not None:
|
|
75
|
+
import base64
|
|
76
|
+
|
|
77
|
+
from smartpipe.parsing.extract import slice_video
|
|
78
|
+
|
|
79
|
+
step = by.slice_seconds
|
|
80
|
+
slices = slice_video(video, seconds=step)
|
|
81
|
+
total = len(slices)
|
|
82
|
+
for position, part in enumerate(slices):
|
|
83
|
+
marker = (
|
|
84
|
+
origin
|
|
85
|
+
if total == 1
|
|
86
|
+
else f"{origin} §{_clock(position * step)}-{_clock((position + 1) * step)}"
|
|
87
|
+
)
|
|
88
|
+
writer.write_record(
|
|
89
|
+
{
|
|
90
|
+
"video_b64": base64.b64encode(part.data).decode("ascii"),
|
|
91
|
+
"mime": part.mime,
|
|
92
|
+
"source": marker,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
if by.unit in ("minutes", "seconds") and (audio := _single(item, AudioData)) is not None:
|
|
97
|
+
import base64
|
|
98
|
+
|
|
99
|
+
from smartpipe.parsing.extract import slice_audio
|
|
100
|
+
|
|
101
|
+
step = by.slice_seconds
|
|
102
|
+
slices = slice_audio(audio, seconds=step)
|
|
103
|
+
total = len(slices)
|
|
104
|
+
for position, part in enumerate(slices):
|
|
105
|
+
marker = (
|
|
106
|
+
origin
|
|
107
|
+
if total == 1
|
|
108
|
+
else f"{origin} §{_clock(position * step)}-{_clock((position + 1) * step)}"
|
|
109
|
+
)
|
|
110
|
+
# audio rides NDJSON as base64 so the next verb can HEAR the slice
|
|
111
|
+
writer.write_record(
|
|
112
|
+
{
|
|
113
|
+
"audio_b64": base64.b64encode(part.data).decode("ascii"),
|
|
114
|
+
"mime": part.mime,
|
|
115
|
+
"source": marker,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
chunks = split_text(item.text, by.amount if by.unit == "tokens" else _DEFAULT_BUDGET_TOKENS)
|
|
120
|
+
total = len(chunks)
|
|
121
|
+
for position, chunk in enumerate(chunks, start=1):
|
|
122
|
+
marker = origin if total == 1 else f"{origin} §{position}/{total}"
|
|
123
|
+
writer.write_record({"text": chunk, "source": marker})
|
|
124
|
+
figures = [part for part in item.media if isinstance(part, ImageData)]
|
|
125
|
+
if figures:
|
|
126
|
+
import base64
|
|
127
|
+
|
|
128
|
+
for position, figure in enumerate(figures, start=1):
|
|
129
|
+
writer.write_record(
|
|
130
|
+
{
|
|
131
|
+
"image_b64": base64.b64encode(figure.data).decode("ascii"),
|
|
132
|
+
"mime": figure.mime,
|
|
133
|
+
"source": f"{origin} img.{position}",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _single(item: Item, kind: type[_M]) -> _M | None:
|
|
139
|
+
for part in item.media:
|
|
140
|
+
if isinstance(part, kind):
|
|
141
|
+
return part
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _clock(seconds: int) -> str:
|
|
146
|
+
return f"{seconds // 60:02d}:{seconds % 60:02d}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _page_figures(path: PathType) -> dict[int, list[ImageData]]:
|
|
150
|
+
"""Figures grouped by 1-based page number (PDF only; the where marker is 'p.N img.M')."""
|
|
151
|
+
from smartpipe.parsing.extract import embedded_images
|
|
152
|
+
|
|
153
|
+
grouped: dict[int, list[ImageData]] = {}
|
|
154
|
+
try:
|
|
155
|
+
media = embedded_images(path)
|
|
156
|
+
except ItemError:
|
|
157
|
+
return {}
|
|
158
|
+
for found in media.images:
|
|
159
|
+
head = found.where.split(" ", 1)[0] # "p.7"
|
|
160
|
+
if head.startswith("p.") and head[2:].isdigit():
|
|
161
|
+
grouped.setdefault(int(head[2:]), []).append(found.image)
|
|
162
|
+
return grouped
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _run_media(request: SplitRequest, context: SplitContext, *, stdout: TextIO) -> ExitCode:
|
|
166
|
+
"""--media (D29): embedded images become items; icons under the floor drop, once-noted."""
|
|
167
|
+
import base64
|
|
168
|
+
from pathlib import Path
|
|
169
|
+
|
|
170
|
+
from smartpipe.io.inputs import expand_globs
|
|
171
|
+
from smartpipe.io.writers import OutputFormat
|
|
172
|
+
from smartpipe.parsing.extract import embedded_images
|
|
173
|
+
|
|
174
|
+
if not request.input.patterns:
|
|
175
|
+
raise UsageFault(
|
|
176
|
+
"--media reads document files — give it some: smartpipe split --media --in 'docs/*.pdf'"
|
|
177
|
+
)
|
|
178
|
+
writer = context.writer(OutputFormat.AUTO, structured=True, stdout=stdout)
|
|
179
|
+
produced = 0
|
|
180
|
+
skipped = 0
|
|
181
|
+
dropped_total = 0
|
|
182
|
+
try:
|
|
183
|
+
for path in expand_globs(request.input.patterns):
|
|
184
|
+
name = Path(path).name
|
|
185
|
+
try:
|
|
186
|
+
media = embedded_images(Path(path))
|
|
187
|
+
except ItemError as exc:
|
|
188
|
+
diagnostics.warn(f"skipped: {name} ({exc})")
|
|
189
|
+
skipped += 1
|
|
190
|
+
continue
|
|
191
|
+
dropped_total += media.dropped_small
|
|
192
|
+
if not media.images:
|
|
193
|
+
diagnostics.note(f"{name} has no embedded images")
|
|
194
|
+
for found in media.images:
|
|
195
|
+
writer.write_record(
|
|
196
|
+
{
|
|
197
|
+
"image_b64": base64.b64encode(found.image.data).decode("ascii"),
|
|
198
|
+
"mime": found.image.mime,
|
|
199
|
+
"source": f"{name} {found.where}",
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
produced += 1
|
|
203
|
+
finally:
|
|
204
|
+
writer.flush()
|
|
205
|
+
if dropped_total:
|
|
206
|
+
plural = "s" if dropped_total != 1 else ""
|
|
207
|
+
diagnostics.note(
|
|
208
|
+
f"skipped {dropped_total} embedded image{plural} under 4 KB (icons/decorations)"
|
|
209
|
+
)
|
|
210
|
+
return outcome_exit_code(done=produced, skipped=skipped)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _run_pages(
|
|
214
|
+
request: SplitRequest,
|
|
215
|
+
context: SplitContext,
|
|
216
|
+
*,
|
|
217
|
+
by: SplitBy,
|
|
218
|
+
stdout: TextIO,
|
|
219
|
+
media: bool = False,
|
|
220
|
+
) -> ExitCode:
|
|
221
|
+
"""--by pages reads PDF FILES directly (page structure dies in extraction);
|
|
222
|
+
with --media, each page item carries that page's figures too (D32)."""
|
|
223
|
+
import base64
|
|
224
|
+
from pathlib import Path
|
|
225
|
+
|
|
226
|
+
from smartpipe.io.inputs import expand_globs
|
|
227
|
+
from smartpipe.io.writers import OutputFormat
|
|
228
|
+
from smartpipe.parsing.extract import pdf_page_texts
|
|
229
|
+
|
|
230
|
+
if not request.input.patterns:
|
|
231
|
+
raise UsageFault(
|
|
232
|
+
"--by pages reads PDF files — give it some:\n"
|
|
233
|
+
" smartpipe split --by pages --in 'docs/*.pdf'"
|
|
234
|
+
)
|
|
235
|
+
writer = context.writer(OutputFormat.AUTO, structured=True, stdout=stdout)
|
|
236
|
+
produced = 0
|
|
237
|
+
skipped = 0
|
|
238
|
+
try:
|
|
239
|
+
for path in expand_globs(request.input.patterns):
|
|
240
|
+
name = Path(path).name
|
|
241
|
+
if Path(path).suffix.lower() != ".pdf":
|
|
242
|
+
diagnostics.warn(
|
|
243
|
+
f"skipped: {name} (--by pages reads PDF files — "
|
|
244
|
+
f"{name} has no fixed pages; use --by tokens)"
|
|
245
|
+
)
|
|
246
|
+
skipped += 1
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
pages = pdf_page_texts(Path(path))
|
|
250
|
+
except ItemError as exc:
|
|
251
|
+
diagnostics.warn(f"skipped: {name} ({exc})")
|
|
252
|
+
skipped += 1
|
|
253
|
+
continue
|
|
254
|
+
figures_by_page = _page_figures(Path(path)) if media else {}
|
|
255
|
+
groups = [pages[i : i + by.amount] for i in range(0, len(pages), by.amount)]
|
|
256
|
+
for index, group in enumerate(groups):
|
|
257
|
+
first = index * by.amount + 1
|
|
258
|
+
last = min(first + by.amount - 1, len(pages))
|
|
259
|
+
span = f"p.{first}" if first == last else f"p.{first}-{last}"
|
|
260
|
+
marker = name if len(groups) == 1 else f"{name} {span}"
|
|
261
|
+
record: dict[str, object] = {
|
|
262
|
+
"text": "\n\n".join(group).strip(),
|
|
263
|
+
"source": marker,
|
|
264
|
+
}
|
|
265
|
+
attached = [
|
|
266
|
+
{
|
|
267
|
+
"image_b64": base64.b64encode(figure.data).decode("ascii"),
|
|
268
|
+
"mime": figure.mime,
|
|
269
|
+
}
|
|
270
|
+
for page in range(first, last + 1)
|
|
271
|
+
for figure in figures_by_page.get(page, ())
|
|
272
|
+
]
|
|
273
|
+
if attached:
|
|
274
|
+
record["parts"] = attached
|
|
275
|
+
writer.write_record(record)
|
|
276
|
+
produced += 1
|
|
277
|
+
finally:
|
|
278
|
+
writer.flush()
|
|
279
|
+
return outcome_exit_code(done=produced, skipped=skipped)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def run_split(
|
|
283
|
+
request: SplitRequest,
|
|
284
|
+
context: SplitContext,
|
|
285
|
+
*,
|
|
286
|
+
stdin: TextIO,
|
|
287
|
+
stdout: TextIO,
|
|
288
|
+
stop: asyncio.Event | None = None,
|
|
289
|
+
) -> ExitCode:
|
|
290
|
+
from smartpipe.io.writers import OutputFormat
|
|
291
|
+
|
|
292
|
+
by = _resolve_by(request)
|
|
293
|
+
if request.media and by.unit != "pages":
|
|
294
|
+
if request.by_flag is not None or request.max_tokens_flag is not None:
|
|
295
|
+
raise UsageFault(
|
|
296
|
+
"--media combines with --by pages (fused page items) or stands alone — "
|
|
297
|
+
"not with token/duration units"
|
|
298
|
+
)
|
|
299
|
+
return await _run_media(request, context, stdout=stdout)
|
|
300
|
+
if by.unit == "pages":
|
|
301
|
+
return await _run_pages(request, context, by=by, stdout=stdout, media=request.media)
|
|
302
|
+
writer = context.writer(OutputFormat.AUTO, structured=True, stdout=stdout)
|
|
303
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
304
|
+
items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
305
|
+
produced = 0
|
|
306
|
+
skipped = 0
|
|
307
|
+
try:
|
|
308
|
+
async for item in items_iter:
|
|
309
|
+
if stop is not None and stop.is_set():
|
|
310
|
+
break
|
|
311
|
+
duration_slicing = by.unit in ("minutes", "seconds")
|
|
312
|
+
has_clip = any(isinstance(part, AudioData | VideoData) for part in item.media)
|
|
313
|
+
if not (duration_slicing and has_clip):
|
|
314
|
+
try:
|
|
315
|
+
item = await ensure_text(item, log=log) # converts, row-noted
|
|
316
|
+
except ItemError as exc:
|
|
317
|
+
diagnostics.warn(f"skipped: {describe_source(item.source)} ({exc})")
|
|
318
|
+
skipped += 1
|
|
319
|
+
continue
|
|
320
|
+
try:
|
|
321
|
+
_write_chunks(writer, item, by)
|
|
322
|
+
except ItemError as exc:
|
|
323
|
+
diagnostics.warn(f"skipped: {describe_source(item.source)} ({exc})")
|
|
324
|
+
skipped += 1
|
|
325
|
+
continue
|
|
326
|
+
produced += 1
|
|
327
|
+
finally:
|
|
328
|
+
writer.flush()
|
|
329
|
+
log.finish()
|
|
330
|
+
if stop is not None and stop.is_set():
|
|
331
|
+
diagnostics.interrupted_summary(processed=produced, skipped=skipped)
|
|
332
|
+
return interrupted_exit_code(done=produced, skipped=skipped)
|
|
333
|
+
return outcome_exit_code(done=produced, skipped=skipped)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The ``summarize`` verb: deterministic aggregation (D38/07, KQL verbatim).
|
|
2
|
+
|
|
3
|
+
Free — the number the analyst actually came for, without leaving for awk.
|
|
4
|
+
One pass, grouped state, KQL's own output naming.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from smartpipe.core.errors import ExitCode
|
|
13
|
+
from smartpipe.engine.aggregate import GroupState, finish, fold, group_key, parse_summarize
|
|
14
|
+
from smartpipe.io import diagnostics
|
|
15
|
+
from smartpipe.io.items import item_from_line
|
|
16
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
|
|
21
|
+
__all__ = ["SummarizeRequest", "run_summarize"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class SummarizeRequest:
|
|
26
|
+
expression: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_summarize(request: SummarizeRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
30
|
+
plan = parse_summarize(request.expression) # UsageFault (64) before reading stdin
|
|
31
|
+
groups: dict[tuple[object, ...], GroupState] = {}
|
|
32
|
+
order: list[tuple[object, ...]] = []
|
|
33
|
+
for index, line in enumerate(stdin):
|
|
34
|
+
if not line.strip():
|
|
35
|
+
continue
|
|
36
|
+
item = item_from_line(line, index)
|
|
37
|
+
record = item.data if item.data is not None else {"text": item.text}
|
|
38
|
+
key = group_key(plan, record)
|
|
39
|
+
state = groups.get(key)
|
|
40
|
+
if state is None:
|
|
41
|
+
state = GroupState()
|
|
42
|
+
groups[key] = state
|
|
43
|
+
order.append(key)
|
|
44
|
+
fold(plan, state, record)
|
|
45
|
+
|
|
46
|
+
writer = make_writer(
|
|
47
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
|
|
48
|
+
)
|
|
49
|
+
ranked = sorted(order, key=lambda key: (-groups[key].count, str(key)))
|
|
50
|
+
for key in ranked:
|
|
51
|
+
writer.write_record(finish(plan, key, groups[key]))
|
|
52
|
+
writer.flush()
|
|
53
|
+
|
|
54
|
+
skipped: dict[str, int] = {}
|
|
55
|
+
for state in groups.values():
|
|
56
|
+
for field_name, count in state.skipped_non_numeric.items():
|
|
57
|
+
skipped[field_name] = skipped.get(field_name, 0) + count
|
|
58
|
+
for field_name, count in skipped.items():
|
|
59
|
+
diagnostics.note(f"summarize: skipped {count:,} non-numeric value(s) of '{field_name}'")
|
|
60
|
+
return ExitCode.OK
|