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/io/progress.py
ADDED
|
@@ -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
|
+
)
|
smartpipe/io/readers.py
ADDED
|
@@ -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
|