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,140 @@
1
+ """Progress feedback — always stderr, always TTY-gated, gone on completion.
2
+
3
+ Spec §6.1: a single spinner line overwritten in place, with count, percent, and
4
+ an ETA that appears only after a few completions. When stderr is not a terminal
5
+ (a cron job, a pipe), progress is suppressed entirely — stdout stays sacred and
6
+ the log stays clean. The render functions are pure; ``Spinner`` adds the clock,
7
+ throttling, and the stderr writes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from smartpipe.io import tty
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+ from typing import TextIO
22
+
23
+ __all__ = ["Spinner", "format_eta", "make_stderr_spinner", "render_known", "render_unknown"]
24
+
25
+ _BRAILLE = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
26
+ _ASCII = "-\\|/"
27
+ _ETA_WARMUP = 5 # completions before an ETA is trustworthy enough to show
28
+ _MIN_REDRAW_S = 0.1 # ≤ 10 fps
29
+ _CLEAR_LINE = "\x1b[K"
30
+
31
+
32
+ def format_eta(seconds: float) -> str:
33
+ total = int(seconds)
34
+ hours, remainder = divmod(total, 3600)
35
+ minutes, secs = divmod(remainder, 60)
36
+ if hours:
37
+ return f"{hours}h{minutes}m"
38
+ if minutes:
39
+ return f"{minutes}m{secs}s"
40
+ return f"{secs}s"
41
+
42
+
43
+ def render_known(frame: str, *, done: int, total: int, eta_seconds: float | None) -> str:
44
+ percent = int(done / total * 100) if total else 100
45
+ line = f"{frame} Processing {total} items [{done}/{total}] {percent}%"
46
+ if eta_seconds is not None:
47
+ line += f" ~{format_eta(eta_seconds)} remaining"
48
+ return line
49
+
50
+
51
+ def render_unknown(
52
+ frame: str, *, done: int, rate: float, matched: int | None = None, extra: str | None = None
53
+ ) -> str:
54
+ line = f"{frame} Processing [{done}] {rate:.1f}/s"
55
+ if matched is not None:
56
+ line += f" · {matched} matched"
57
+ if extra:
58
+ line += f" · {extra}"
59
+ return line
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class Spinner:
64
+ stream: TextIO
65
+ enabled: bool
66
+ ascii_only: bool
67
+ clock: Callable[[], float]
68
+ total: int | None = None
69
+ matched: int | None = None # filter's status-line segment
70
+ extra: str | None = None # map's live --tally segment
71
+ _done: int = 0
72
+ _start: float = 0.0
73
+ _last_draw: float = field(default=-1.0)
74
+ _frame: int = 0
75
+ _drew: bool = False
76
+
77
+ def start(self, total: int | None) -> None:
78
+ self.total = total
79
+ self._done = 0
80
+ self._start = self.clock()
81
+ self._last_draw = -1.0
82
+
83
+ def advance(self) -> None:
84
+ self._done += 1
85
+ if not self.enabled:
86
+ return
87
+ now = self.clock()
88
+ is_last = self.total is not None and self._done >= self.total
89
+ if not is_last and now - self._last_draw < _MIN_REDRAW_S:
90
+ return
91
+ self._last_draw = now
92
+ self._draw(now)
93
+
94
+ def finish(self) -> None:
95
+ if self.enabled and self._drew:
96
+ self.stream.write(f"\r{_CLEAR_LINE}")
97
+ self.stream.flush()
98
+
99
+ def _color(self) -> bool:
100
+ import os
101
+
102
+ return self.enabled and not os.environ.get("NO_COLOR")
103
+
104
+ def _draw(self, now: float) -> None:
105
+ frames = _ASCII if self.ascii_only else _BRAILLE
106
+ frame = frames[self._frame % len(frames)]
107
+ self._frame += 1
108
+ elapsed = max(now - self._start, 1e-9)
109
+ rate = self._done / elapsed
110
+ if self.total is None:
111
+ line = render_unknown(
112
+ frame, done=self._done, rate=rate, matched=self.matched, extra=self.extra
113
+ )
114
+ else:
115
+ eta = (self.total - self._done) / rate if self._done >= _ETA_WARMUP and rate else None
116
+ line = render_known(frame, done=self._done, total=self.total, eta_seconds=eta)
117
+ from smartpipe.io import metering
118
+
119
+ consumed = metering.status_segment() # D40: live observed units
120
+ if self._color():
121
+ line = f"\x1b[36m{frame}\x1b[0m{line[len(frame) :]}"
122
+ if consumed:
123
+ line += f" \x1b[2m{consumed}\x1b[0m"
124
+ elif consumed:
125
+ line += f" {consumed}"
126
+ self.stream.write(f"\r{line}{_CLEAR_LINE}")
127
+ self.stream.flush()
128
+ self._drew = True
129
+
130
+
131
+ def make_stderr_spinner() -> Spinner:
132
+ """A spinner wired to the real stderr — enabled only when stderr is a TTY,
133
+ with a Braille or ASCII frame set depending on the encoding."""
134
+ encoding = (sys.stderr.encoding or "").lower()
135
+ return Spinner(
136
+ stream=sys.stderr,
137
+ enabled=tty.stderr_is_tty(),
138
+ ascii_only="utf" not in encoding,
139
+ clock=time.monotonic,
140
+ )
@@ -0,0 +1,455 @@
1
+ """Item sources — all with the same shape: ``AsyncIterator[Item]``.
2
+
3
+ Stdin is read **incrementally** (stage-08 as amended): a daemon pump thread does the
4
+ blocking ``readline`` and hands lines to a bounded asyncio queue, so items flow as
5
+ they arrive (``tail -f`` works), backpressure is real (the pump stalls when the queue
6
+ fills), and shutdown can never hang on a blocked read (the async side is cancellable;
7
+ the daemon flag is the last-resort guarantee). ``--in`` file lists stay finite.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import concurrent.futures
14
+ import contextlib
15
+ import os
16
+ import threading
17
+ from dataclasses import dataclass, replace
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from smartpipe.core.errors import ItemError, SetupFault, UsageFault
22
+ from smartpipe.io import diagnostics
23
+ from smartpipe.io.items import Item, item_from_file, item_from_line
24
+ from smartpipe.models.base import AudioData, ImageData, VideoData
25
+ from smartpipe.parsing.detect import FileKind, detect_kind, route
26
+ from smartpipe.parsing.extract import MissingExtra, extract
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import AsyncIterator, Sequence
30
+ from typing import TextIO
31
+
32
+ from smartpipe.io.inputs import InputSpec
33
+
34
+ __all__ = [
35
+ "ensure_not_a_tty",
36
+ "figure_note",
37
+ "file_items",
38
+ "from_files_items",
39
+ "resolve_items",
40
+ "stdin_items",
41
+ ]
42
+
43
+ _HEAD_BYTES = 8192
44
+ _QUEUE_MAX = 1024 # lines buffered ahead of consumption — memory stays bounded
45
+
46
+
47
+ def resolve_items(
48
+ spec: InputSpec, stdin: TextIO, *, stop: asyncio.Event | None = None
49
+ ) -> tuple[AsyncIterator[Item], int | None]:
50
+ """The single entry point every verb uses: dispatch on the input flags.
51
+
52
+ Returns ``(items, total)`` — total is known only for ``--in`` file lists;
53
+ stdin is a stream (``tail -f`` works), so its total is ``None`` and the
54
+ spinner shows count+rate instead of an ETA. Only the stdin paths guard
55
+ against a bare terminal."""
56
+ from smartpipe.io.inputs import expand_globs
57
+
58
+ if spec.patterns and spec.from_files:
59
+ raise UsageFault(
60
+ "--in and --from-files are both file sources — use one\n"
61
+ " --in takes globs; --from-files reads filenames from stdin."
62
+ )
63
+ if spec.patterns:
64
+ loaded = file_items(expand_globs(spec.patterns)) # UsageFault if no match
65
+ if stdin.isatty(): # files only — no pipe to chain
66
+ return _iter_list(loaded), len(loaded)
67
+ # spec §8: mixed input is files first (glob-sorted), then stdin lines
68
+ return _chain_files_then_stdin(loaded, stdin, stop), None
69
+ ensure_not_a_tty(stdin)
70
+ if spec.from_files:
71
+ return from_files_items(stdin, stop=stop), None
72
+ return stdin_items(stdin, stop=stop), None
73
+
74
+
75
+ async def _chain_files_then_stdin(
76
+ loaded: Sequence[Item], stdin: TextIO, stop: asyncio.Event | None
77
+ ) -> AsyncIterator[Item]:
78
+ for item in loaded:
79
+ yield item
80
+ async for item in stdin_items(stdin, stop=stop):
81
+ yield item
82
+
83
+
84
+ async def _iter_list(items: Sequence[Item]) -> AsyncIterator[Item]:
85
+ for item in items:
86
+ yield item
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class _StdinDocument:
91
+ tmp_name: str
92
+ kind: FileKind
93
+
94
+
95
+ # A queue message: ("line", text) · ("document", _StdinDocument) ·
96
+ # ("image", ImageData) · ("fatal", screen) · None = EOF.
97
+ _Message = tuple[str, object] | None
98
+
99
+ _KIND_SUFFIX: dict[FileKind, str] = {
100
+ FileKind.PDF: ".pdf",
101
+ FileKind.DOCX: ".docx",
102
+ FileKind.XLSX: ".xlsx",
103
+ FileKind.PPTX: ".pptx",
104
+ FileKind.HTML: ".html",
105
+ FileKind.EPUB: ".epub",
106
+ FileKind.AUDIO: ".mp3",
107
+ }
108
+
109
+
110
+ async def _messages(stdin: TextIO, stop: asyncio.Event | None) -> AsyncIterator[tuple[str, object]]:
111
+ """Incremental stdin source: daemon pump thread → bounded queue → cancellable get.
112
+
113
+ Real streams are read with ``os.read`` on the raw fd, NOT ``stdin.readline()``:
114
+ a thread blocked in ``readline`` holds the TextIOWrapper's lock, and CPython's
115
+ interpreter-shutdown finalization then deadlocks trying to close the stream —
116
+ the exact hang the streaming e2e caught. On the fd path the FIRST read also
117
+ sniffs (stage-07 task 4): a binary document redirected to stdin becomes one
118
+ spooled document message; text proceeds as lines with the sniffed bytes as the
119
+ carry. Objects without a usable fd (StringIO in tests) fall back to
120
+ ``readline`` — text-only by construction, never blocking, never sniffed.
121
+ """
122
+ queue: asyncio.Queue[_Message] = asyncio.Queue(_QUEUE_MAX)
123
+ loop = asyncio.get_running_loop()
124
+
125
+ def put(message: _Message) -> None:
126
+ asyncio.run_coroutine_threadsafe(queue.put(message), loop).result()
127
+
128
+ def pump_text_fd(fd: int, carry: bytes) -> None:
129
+ buffer = bytearray(carry)
130
+ while True:
131
+ while (newline := buffer.find(0x0A)) != -1:
132
+ line = bytes(buffer[: newline + 1])
133
+ del buffer[: newline + 1]
134
+ put(("line", line.decode("utf-8", errors="replace")))
135
+ chunk = os.read(fd, 65536) # blocks WITHOUT holding any io lock
136
+ if not chunk:
137
+ if buffer: # final line without a trailing newline
138
+ put(("line", bytes(buffer).decode("utf-8", errors="replace")))
139
+ return
140
+ buffer += chunk
141
+
142
+ def spool_document(fd: int, head: bytes, kind: FileKind) -> None:
143
+ import tempfile
144
+
145
+ suffix = _KIND_SUFFIX.get(kind, "")
146
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as handle:
147
+ handle.write(head)
148
+ while chunk := os.read(fd, 65536):
149
+ handle.write(chunk)
150
+ put(("document", _StdinDocument(handle.name, kind)))
151
+
152
+ def collect_image(fd: int, head: bytes) -> None:
153
+ data = bytearray(head)
154
+ while chunk := os.read(fd, 65536):
155
+ data += chunk
156
+ mime = _magic_image_mime(bytes(data[:16]))
157
+ put(("image", ImageData(data=bytes(data), mime=mime)))
158
+
159
+ def collect_audio(fd: int, head: bytes, kind: FileKind) -> None:
160
+ data = bytearray(head)
161
+ while chunk := os.read(fd, 65536):
162
+ data += chunk
163
+ from smartpipe.parsing.detect import audio_mime
164
+
165
+ suffix = _KIND_SUFFIX.get(kind, ".mp3")
166
+ put(("audio", AudioData(data=bytes(data), mime=audio_mime(Path(f"x{suffix}")))))
167
+
168
+ def collect_video(fd: int, head: bytes) -> None:
169
+ data = bytearray(head)
170
+ while chunk := os.read(fd, 65536):
171
+ data += chunk
172
+ put(("video", VideoData(data=bytes(data), mime="video/mp4")))
173
+
174
+ def pump_fd(fd: int) -> None:
175
+ head = os.read(fd, _HEAD_BYTES) # one read — a live stream must not stall here
176
+ if not head:
177
+ return # empty stdin
178
+ kind = detect_kind(Path("<stdin>"), head)
179
+ match route(kind):
180
+ case "text":
181
+ pump_text_fd(fd, head)
182
+ case "doc":
183
+ spool_document(fd, head, kind)
184
+ case "audio":
185
+ collect_audio(fd, head, kind)
186
+ case "video":
187
+ collect_video(fd, head)
188
+ case "image":
189
+ collect_image(fd, head)
190
+ case "skip":
191
+ from smartpipe.cli import screens
192
+
193
+ put(("fatal", screens.BINARY_STDIN_UNPARSEABLE))
194
+
195
+ def pump_readline() -> None:
196
+ while True:
197
+ line = stdin.readline() # non-blocking sources only (StringIO et al.)
198
+ if not line:
199
+ return
200
+ put(("line", line))
201
+
202
+ def pump() -> None:
203
+ try:
204
+ fd: int | None
205
+ try:
206
+ fd = stdin.fileno()
207
+ except (OSError, ValueError, AttributeError):
208
+ fd = None
209
+ if fd is None:
210
+ pump_readline()
211
+ else:
212
+ pump_fd(fd)
213
+ except (RuntimeError, ValueError, OSError, concurrent.futures.CancelledError):
214
+ # loop closed, consumer gone, or the stream was closed under us —
215
+ # any of these means "stop pumping", never a crash or a stderr trace
216
+ return
217
+ finally:
218
+ sentinel = queue.put(None) # blocking put — a full queue can't swallow EOF
219
+ try:
220
+ asyncio.run_coroutine_threadsafe(sentinel, loop).result()
221
+ except (RuntimeError, concurrent.futures.CancelledError):
222
+ sentinel.close() # loop gone — don't leave a never-awaited coroutine
223
+
224
+ threading.Thread(target=pump, name="smartpipe-stdin-pump", daemon=True).start()
225
+ while True:
226
+ message = await _next_or_stop(queue, stop)
227
+ if message is None:
228
+ return
229
+ yield message
230
+
231
+
232
+ def _magic_image_mime(head: bytes) -> str:
233
+ if head.startswith(b"\x89PNG"):
234
+ return "image/png"
235
+ if head.startswith(b"\xff\xd8\xff"):
236
+ return "image/jpeg"
237
+ if head.startswith((b"GIF87a", b"GIF89a")):
238
+ return "image/gif"
239
+ return "image/webp" if head[8:12] == b"WEBP" else "image/png"
240
+
241
+
242
+ async def _next_or_stop(queue: asyncio.Queue[_Message], stop: asyncio.Event | None) -> _Message:
243
+ if stop is None:
244
+ return await queue.get()
245
+ if stop.is_set():
246
+ return None
247
+ get_task = asyncio.ensure_future(queue.get())
248
+ stop_task = asyncio.ensure_future(stop.wait())
249
+ done, _pending = await asyncio.wait({get_task, stop_task}, return_when=asyncio.FIRST_COMPLETED)
250
+ if get_task in done:
251
+ stop_task.cancel()
252
+ return get_task.result()
253
+ get_task.cancel()
254
+ with contextlib.suppress(asyncio.CancelledError):
255
+ await get_task # reap it — no strays, no destroyed-pending warnings
256
+ return None
257
+
258
+
259
+ async def _lines(stdin: TextIO, stop: asyncio.Event | None) -> AsyncIterator[str]:
260
+ """Text-only view of the message stream (``--from-files`` wants filenames)."""
261
+ async for message in _messages(stdin, stop):
262
+ tag, payload = message
263
+ if tag != "line":
264
+ raise SetupFault(
265
+ "error: --from-files expects filenames on stdin, got a binary document\n"
266
+ " Pipe a list of paths in, e.g.:\n"
267
+ " find . -name '*.md' | smartpipe map … --from-files"
268
+ )
269
+ assert isinstance(payload, str)
270
+ yield payload
271
+
272
+
273
+ async def stdin_items(stdin: TextIO, *, stop: asyncio.Event | None = None) -> AsyncIterator[Item]:
274
+ """Each stdin line is one Item, yielded as it arrives (never waits for EOF).
275
+
276
+ A redirected binary document (``smartpipe map … < report.pdf``) is ONE item:
277
+ spooled, extracted, source ``<stdin>`` (stage-07 task 4). A final line without
278
+ a trailing newline is still an item; empty input yields nothing; CRLF and the
279
+ line-0 BOM are handled per-item by ``item_from_line``.
280
+ """
281
+
282
+ index = 0
283
+ async for message in _messages(stdin, stop):
284
+ tag, payload = message
285
+ if tag == "line":
286
+ assert isinstance(payload, str)
287
+ yield item_from_line(payload, index)
288
+ index += 1
289
+ elif tag == "document":
290
+ assert isinstance(payload, _StdinDocument)
291
+ yield await asyncio.to_thread(_extract_stdin_document, payload.tmp_name, payload.kind)
292
+ elif tag == "image":
293
+ assert isinstance(payload, ImageData)
294
+ item = item_from_file("", "<stdin>", 0)
295
+ yield replace(item, media=(payload,))
296
+ elif tag == "audio":
297
+ assert isinstance(payload, AudioData)
298
+ item = item_from_file("", "<stdin>", 0)
299
+ yield replace(item, media=(payload,))
300
+ elif tag == "video":
301
+ assert isinstance(payload, VideoData)
302
+ item = item_from_file("", "<stdin>", 0)
303
+ yield replace(item, media=(payload,))
304
+ else: # "fatal"
305
+ assert isinstance(payload, str)
306
+ raise SetupFault(payload)
307
+
308
+
309
+ def _extract_stdin_document(tmp_name: str, kind: FileKind) -> Item:
310
+ from smartpipe.cli import screens
311
+
312
+ path = Path(tmp_name)
313
+ try:
314
+ extracted = extract(path, kind)
315
+ except MissingExtra as exc:
316
+ raise SetupFault(screens.stdin_document_failed(exc.guidance.splitlines()[0])) from exc
317
+ except ItemError as exc:
318
+ raise SetupFault(screens.stdin_document_failed(str(exc))) from exc
319
+ finally:
320
+ with contextlib.suppress(OSError):
321
+ path.unlink() # statelessness: the spool never outlives the run
322
+ if extracted.warning is not None:
323
+ diagnostics.warn(f"<stdin>: {extracted.warning}")
324
+ return item_from_file(extracted.text, "<stdin>", 0)
325
+
326
+
327
+ async def from_files_items(
328
+ stdin: TextIO, *, stop: asyncio.Event | None = None
329
+ ) -> AsyncIterator[Item]:
330
+ """``--from-files``: each non-blank stdin line names a file to read — also
331
+ incremental, so ``find … | smartpipe … --from-files`` processes as names arrive."""
332
+ from pathlib import Path
333
+
334
+ warned_extras: set[str] = set()
335
+ index = 0
336
+ async for line in _lines(stdin, stop):
337
+ name = line.strip()
338
+ if not name:
339
+ continue
340
+ item = _load_file(Path(name), index, warned_extras)
341
+ if item is not None:
342
+ yield item
343
+ index += 1
344
+
345
+
346
+ def file_items(paths: Sequence[Path]) -> list[Item]:
347
+ """Each file is one item. Unreadable, unparseable, or missing-dependency files
348
+ are skipped with a warning (spec §6.3) — the run never crashes on a bad file."""
349
+ warned_extras: set[str] = set()
350
+ items: list[Item] = []
351
+ for index, path in enumerate(paths):
352
+ item = _load_file(path, index, warned_extras)
353
+ if item is not None:
354
+ items.append(item)
355
+ return items
356
+
357
+
358
+ def _load_file(path: Path, index: int, warned_extras: set[str]) -> Item | None:
359
+ try:
360
+ with path.open("rb") as handle:
361
+ head = handle.read(_HEAD_BYTES)
362
+ except OSError as exc:
363
+ diagnostics.warn(f"skipped: {path} (cannot read: {exc.strerror or exc})")
364
+ return None
365
+ kind = detect_kind(path, head)
366
+ if route(kind) in ("audio", "video"):
367
+ # D20/D27: media carries its BYTES — conversion is lazy and per-verb
368
+ # (map tries the native wire first; text verbs convert on demand)
369
+ from smartpipe.parsing.detect import audio_mime, video_mime
370
+
371
+ try:
372
+ data = path.read_bytes()
373
+ except OSError as exc:
374
+ diagnostics.warn(f"skipped: {path} (cannot read: {exc.strerror or exc})")
375
+ return None
376
+ item = item_from_file("", str(path), index)
377
+ media = (
378
+ AudioData(data=data, mime=audio_mime(path))
379
+ if route(kind) == "audio"
380
+ else VideoData(data=data, mime=video_mime(path))
381
+ )
382
+ return replace(item, media=(media,))
383
+ try:
384
+ extracted = extract(path, kind)
385
+ except MissingExtra as exc:
386
+ if exc.extra not in warned_extras:
387
+ diagnostics.warn(exc.guidance)
388
+ warned_extras.add(exc.extra)
389
+ return None
390
+ except ItemError as exc:
391
+ diagnostics.warn(f"skipped: {path} ({exc})")
392
+ return None
393
+ if extracted.warning is not None:
394
+ diagnostics.warn(f"{path}: {extracted.warning}")
395
+ item = item_from_file(extracted.text, str(path), index)
396
+ if extracted.image is not None:
397
+ return replace(item, media=(extracted.image,)) # map sends it to a vision model
398
+ figures = _document_figures(path, kind, extracted.text)
399
+ if figures:
400
+ return replace(item, media=figures)
401
+ return item
402
+
403
+
404
+ _FIGURE_CAP = 8 # request-size and cost sanity per document item (D32)
405
+ _FIGURE_KINDS = {FileKind.PDF, FileKind.DOCX, FileKind.PPTX, FileKind.XLSX}
406
+
407
+
408
+ _THIN_TEXT = 64 # under this many chars, a figure-bearing document reads as a scan
409
+
410
+
411
+ def _document_figures(path: Path, kind: FileKind, text: str) -> tuple[ImageData, ...]:
412
+ """D32: a document item carries its embedded figures by default — capped,
413
+ icon-floored, announced once per file. D39/03: when the text layer is
414
+ THIN, the announcement says so — a scanned document routed to the vision
415
+ path must never look like silent emptiness."""
416
+ if kind not in _FIGURE_KINDS:
417
+ return ()
418
+ from smartpipe.parsing.extract import MissingExtra, embedded_images
419
+
420
+ try:
421
+ media = embedded_images(path)
422
+ except (MissingExtra, ItemError):
423
+ return () # text still flows; --media names scan problems loudly
424
+ total = len(media.images)
425
+ if total == 0:
426
+ return ()
427
+ kept = media.images[:_FIGURE_CAP]
428
+ capped = total - len(kept)
429
+ diagnostics.note(figure_note(path.name, len(text.strip()), len(kept), capped))
430
+ return tuple(found.image for found in kept)
431
+
432
+
433
+ def figure_note(name: str, text_length: int, kept: int, capped: int) -> str:
434
+ if text_length < _THIN_TEXT:
435
+ hint = (
436
+ f" ({capped} more capped — split --by pages --media processes every page)"
437
+ if capped
438
+ else ""
439
+ )
440
+ return (
441
+ f"{name}: thin text layer ({text_length} chars) — scanned? "
442
+ f"routed {kept} page image(s) to the vision path{hint}"
443
+ )
444
+ suffix = f" ({capped} more capped)" if capped else ""
445
+ plural = "s" if kept != 1 else ""
446
+ return f"{name}: {kept} figure{plural} attached{suffix}"
447
+
448
+
449
+ def ensure_not_a_tty(stdin: TextIO) -> None:
450
+ """A kind guardrail: bare `smartpipe map ...` at a terminal would silently wait."""
451
+ if stdin.isatty():
452
+ raise UsageFault(
453
+ "reading from a terminal — pipe some input in, e.g.:\n"
454
+ ' cat notes.txt | smartpipe map "..."'
455
+ )
smartpipe/io/text.py ADDED
@@ -0,0 +1,40 @@
1
+ """Terminal display width — cells, not code points (DEFER-2).
2
+
3
+ Stdlib-only by design: ``wcwidth`` stays outside the dependency budget. The
4
+ rule: combining marks and zero-width format characters (ZWJ et al.) count 0,
5
+ East-Asian Wide/Fullwidth count 2, everything else counts 1. Emoji-ZWJ
6
+ sequences therefore measure as the *sum of their parts* — approximate on
7
+ purpose, and documented as such wherever alignment matters.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import unicodedata
13
+
14
+ __all__ = ["clip_to_width", "display_width"]
15
+
16
+ _WIDE = frozenset({"W", "F"})
17
+
18
+
19
+ def _char_width(char: str) -> int:
20
+ if unicodedata.combining(char):
21
+ return 0
22
+ if unicodedata.category(char) == "Cf": # ZWJ, ZWNJ, direction marks — zero cells
23
+ return 0
24
+ return 2 if unicodedata.east_asian_width(char) in _WIDE else 1
25
+
26
+
27
+ def display_width(text: str) -> int:
28
+ """How many terminal cells ``text`` occupies (approximate for emoji-ZWJ)."""
29
+ return sum(_char_width(char) for char in text)
30
+
31
+
32
+ def clip_to_width(text: str, budget: int) -> str:
33
+ """The longest prefix of ``text`` that fits in ``budget`` cells — a wide
34
+ character that would straddle the boundary is dropped, never split."""
35
+ used = 0
36
+ for position, char in enumerate(text):
37
+ used += _char_width(char)
38
+ if used > budget:
39
+ return text[:position]
40
+ return text