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.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
@@ -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