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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Window bookkeeping for streaming ``reduce`` (stage-08, spec §4.2) — pure.
|
|
2
|
+
|
|
3
|
+
Semantics (pinned): the first window emits at item ``size`` (a full window);
|
|
4
|
+
thereafter one window per ``every`` new items, each containing the last ``size``
|
|
5
|
+
items. ``flush()`` returns whatever arrived after the last emission as a
|
|
6
|
+
``partial=True`` window — Ctrl+C never silently discards buffered lines. With
|
|
7
|
+
``every == size`` this is tumbling; smaller ``every`` slides. ``every > size``
|
|
8
|
+
would skip items, so the policy rejects it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import deque
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
__all__ = ["Window", "WindowBuffer", "WindowPolicy"]
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class WindowPolicy:
|
|
24
|
+
size: int
|
|
25
|
+
every: int # == size → tumbling; < size → sliding
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if self.size < 1:
|
|
29
|
+
raise ValueError(f"window size must be >= 1, got {self.size}")
|
|
30
|
+
if not 1 <= self.every <= self.size:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"--every must be between 1 and the window size ({self.size}), got {self.every}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Window(Generic[T]):
|
|
38
|
+
items: tuple[T, ...]
|
|
39
|
+
end_index: int # 1-based ordinal of the last item, over the whole stream
|
|
40
|
+
partial: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class WindowBuffer(Generic[T]):
|
|
45
|
+
"""Tiny mutable state machine (documented Spinner-style exception to the
|
|
46
|
+
frozen-by-default rule): push items, get a Window when a boundary lands."""
|
|
47
|
+
|
|
48
|
+
policy: WindowPolicy
|
|
49
|
+
_buffer: deque[T] = field(init=False)
|
|
50
|
+
_seen: int = 0
|
|
51
|
+
_since_emit: int = 0
|
|
52
|
+
_emitted_any: bool = False
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
self._buffer = deque(maxlen=self.policy.size)
|
|
56
|
+
|
|
57
|
+
def push(self, item: T) -> Window[T] | None:
|
|
58
|
+
self._buffer.append(item)
|
|
59
|
+
self._seen += 1
|
|
60
|
+
self._since_emit += 1
|
|
61
|
+
boundary = (
|
|
62
|
+
self._seen >= self.policy.size and self._since_emit >= self.policy.every
|
|
63
|
+
if self._emitted_any
|
|
64
|
+
else self._seen == self.policy.size
|
|
65
|
+
)
|
|
66
|
+
if not boundary:
|
|
67
|
+
return None
|
|
68
|
+
self._emitted_any = True
|
|
69
|
+
self._since_emit = 0
|
|
70
|
+
return Window(items=tuple(self._buffer), end_index=self._seen, partial=False)
|
|
71
|
+
|
|
72
|
+
def flush(self) -> Window[T] | None:
|
|
73
|
+
"""The trailing partial window: everything that arrived after the last
|
|
74
|
+
emission (or the whole short stream, if nothing ever emitted)."""
|
|
75
|
+
if self._since_emit == 0 or self._seen == 0:
|
|
76
|
+
return None
|
|
77
|
+
tail = list(self._buffer)[-self._since_emit :] if self._emitted_any else list(self._buffer)
|
|
78
|
+
return Window(items=tuple(tail), end_index=self._seen, partial=True)
|
smartpipe/io/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""I/O adapters: stdin/file readers, TTY-adaptive writers, stderr diagnostics.
|
|
2
|
+
|
|
3
|
+
Boundary rule (plan/architecture.md): only ``writers`` touches stdout; only
|
|
4
|
+
``diagnostics`` and ``progress`` touch stderr.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Every user-facing stderr message flows through here (stdout stays sacred).
|
|
2
|
+
|
|
3
|
+
Message style contract: plan/ux.md "Error message style" — one-line what, short
|
|
4
|
+
why, copy-pasteable fix. Screens arrive pre-formatted with their own ``error:``
|
|
5
|
+
prefix; bare fault messages get the prefix added.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
from typing import NoReturn
|
|
13
|
+
|
|
14
|
+
from smartpipe.core.errors import (
|
|
15
|
+
ExitCode,
|
|
16
|
+
SempipeError,
|
|
17
|
+
SetupFault,
|
|
18
|
+
TooManyFailures,
|
|
19
|
+
UsageFault,
|
|
20
|
+
)
|
|
21
|
+
from smartpipe.io import tty
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DegradationLog",
|
|
25
|
+
"die",
|
|
26
|
+
"drain_timed_out",
|
|
27
|
+
"internal_error",
|
|
28
|
+
"interrupted_summary",
|
|
29
|
+
"note",
|
|
30
|
+
"preview",
|
|
31
|
+
"report_error",
|
|
32
|
+
"warn",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_RED = "\x1b[31m"
|
|
36
|
+
_RESET = "\x1b[0m"
|
|
37
|
+
_ISSUES_URL = "https://github.com/prabal-rje/smartpipe/issues/new"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _paint(text: str, code: str) -> str:
|
|
41
|
+
"""stderr color, honestly gated: TTY only, NO_COLOR wins (D42)."""
|
|
42
|
+
import os
|
|
43
|
+
|
|
44
|
+
if not tty.stderr_is_tty() or os.environ.get("NO_COLOR"):
|
|
45
|
+
return text
|
|
46
|
+
return f"\x1b[{code}m{text}{_RESET}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def warn(message: str) -> None:
|
|
50
|
+
sys.stderr.write(_paint(f"⚠ {message}", "33") + "\n") # yellow — worth a glance
|
|
51
|
+
sys.stderr.flush()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def preview(message: str) -> None:
|
|
55
|
+
"""Informational cost/awareness lines (D18/D21): TTY-only, never in pipes/cron."""
|
|
56
|
+
if tty.stderr_is_tty():
|
|
57
|
+
sys.stderr.write(f"{message}\n")
|
|
58
|
+
sys.stderr.flush()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def note(message: str) -> None:
|
|
62
|
+
sys.stderr.write(_paint(f"note: {message}", "2") + "\n") # dim — informative, calm
|
|
63
|
+
sys.stderr.flush()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def interrupted_summary(*, processed: int, skipped: int) -> None:
|
|
67
|
+
"""The ux.md §12 drain summary — exact wording is contract."""
|
|
68
|
+
sys.stderr.write(
|
|
69
|
+
_paint(f"done: interrupted — {processed} processed · {skipped} skipped", "33") + "\n"
|
|
70
|
+
)
|
|
71
|
+
sys.stderr.flush()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def drain_timed_out() -> None:
|
|
75
|
+
sys.stderr.write("done: interrupted — drain timed out\n")
|
|
76
|
+
sys.stderr.flush()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _emit_error(text: str) -> None:
|
|
80
|
+
if tty.stderr_supports_color() and text.startswith("error:"):
|
|
81
|
+
text = f"{_RED}error:{_RESET}{text.removeprefix('error:')}"
|
|
82
|
+
sys.stderr.write(f"{text}\n")
|
|
83
|
+
sys.stderr.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_DEGRADE_CAP = 5 # per conversion kind: first rows verbatim, then the rollup
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DegradationLog:
|
|
90
|
+
"""Per-run ledger of poor-man's conversions (D27): every degraded row is
|
|
91
|
+
announced (capped per kind), and one rollup line closes the run."""
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
self.counts: dict[str, int] = {}
|
|
95
|
+
|
|
96
|
+
def note(self, where: str, kind: str, detail: str) -> None:
|
|
97
|
+
count = self.counts.get(kind, 0) + 1
|
|
98
|
+
self.counts[kind] = count
|
|
99
|
+
if count <= _DEGRADE_CAP:
|
|
100
|
+
warn(f"degraded: {where} {kind} ({detail})")
|
|
101
|
+
elif count == _DEGRADE_CAP + 1:
|
|
102
|
+
warn(f"more {kind} rows follow (suppressed; the rollup lands at the end)")
|
|
103
|
+
|
|
104
|
+
def finish(self) -> None:
|
|
105
|
+
if not self.counts:
|
|
106
|
+
return
|
|
107
|
+
ranked = sorted(self.counts.items(), key=lambda pair: -pair[1])
|
|
108
|
+
marks = " · ".join(f"{kind} ×{count:,}" for kind, count in ranked) # noqa: RUF001 — the pinned rollup mark
|
|
109
|
+
note(f"degraded: {marks}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def report_error(screen: str) -> None:
|
|
113
|
+
"""Emit a full error screen without exiting — for commands that own their
|
|
114
|
+
exit code after cleanup (e.g. ``smartpipe schema``'s empty-stdout guarantee)."""
|
|
115
|
+
_emit_error(screen if screen.startswith("error:") else f"error: {screen}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def die(fault: SempipeError, *, debug: bool = False) -> NoReturn:
|
|
119
|
+
message = str(fault)
|
|
120
|
+
_emit_error(message if message.startswith("error:") else f"error: {message}")
|
|
121
|
+
if debug:
|
|
122
|
+
sys.stderr.write("".join(traceback.format_exception(fault)))
|
|
123
|
+
sys.stderr.flush()
|
|
124
|
+
match fault:
|
|
125
|
+
case UsageFault():
|
|
126
|
+
raise SystemExit(int(ExitCode.USAGE))
|
|
127
|
+
case SetupFault():
|
|
128
|
+
raise SystemExit(int(ExitCode.SETUP))
|
|
129
|
+
case TooManyFailures():
|
|
130
|
+
raise SystemExit(int(ExitCode.ALL_FAILED))
|
|
131
|
+
case _:
|
|
132
|
+
# ItemError (or the bare base) reaching die() is a programming error:
|
|
133
|
+
# per the taxonomy those are handled by the runner, not fatal paths.
|
|
134
|
+
raise SystemExit(int(ExitCode.BUG))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def internal_error(exc: BaseException, *, debug: bool) -> NoReturn:
|
|
138
|
+
summary = f"{type(exc).__name__}: {exc}".splitlines()[0]
|
|
139
|
+
_emit_error("error: internal error — this is a bug in smartpipe, not in your usage")
|
|
140
|
+
sys.stderr.write(f" {summary}\n")
|
|
141
|
+
if debug:
|
|
142
|
+
sys.stderr.write("".join(traceback.format_exception(exc)))
|
|
143
|
+
sys.stderr.write(f" Please report it: {_ISSUES_URL}\n")
|
|
144
|
+
else:
|
|
145
|
+
sys.stderr.write(" Rerun with --debug for the full traceback, and please report it:\n")
|
|
146
|
+
sys.stderr.write(f" {_ISSUES_URL}\n")
|
|
147
|
+
sys.stderr.flush()
|
|
148
|
+
raise SystemExit(int(ExitCode.BUG))
|
smartpipe/io/inputs.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Where items come from (spec §8): stdin lines, a glob of files, or a list of
|
|
2
|
+
filenames on stdin. ``InputSpec`` captures the flags; ``expand_globs`` resolves
|
|
3
|
+
``--in`` patterns to a sorted, de-duplicated file list.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import glob
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from smartpipe.core.errors import UsageFault
|
|
14
|
+
|
|
15
|
+
__all__ = ["STDIN", "InputSpec", "expand_globs"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class InputSpec:
|
|
20
|
+
patterns: tuple[str, ...] # --in globs (may be empty)
|
|
21
|
+
from_files: bool # --from-files: each stdin line names a file
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# The default: no file flags → read stdin lines. Shared because it's immutable.
|
|
25
|
+
STDIN = InputSpec(patterns=(), from_files=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def expand_globs(patterns: tuple[str, ...]) -> list[Path]:
|
|
29
|
+
"""Resolve ``--in`` patterns to existing files, sorted, first-seen deduped.
|
|
30
|
+
An all-empty match is a usage error (exit 64) — silence would look like success."""
|
|
31
|
+
seen: dict[str, Path] = {}
|
|
32
|
+
for pattern in patterns:
|
|
33
|
+
expanded = os.path.expanduser(pattern)
|
|
34
|
+
for match in sorted(glob.glob(expanded, recursive=True)):
|
|
35
|
+
path = Path(match)
|
|
36
|
+
if path.is_file():
|
|
37
|
+
seen.setdefault(str(path), path)
|
|
38
|
+
if not seen:
|
|
39
|
+
joined = " ".join(patterns)
|
|
40
|
+
raise UsageFault(
|
|
41
|
+
f"no files matched: {joined}\n"
|
|
42
|
+
" check the pattern, and quote it so the shell doesn't expand it first: --in '*.pdf'"
|
|
43
|
+
)
|
|
44
|
+
return list(seen.values())
|
smartpipe/io/items.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The Item model: the unit every verb operates on.
|
|
2
|
+
|
|
3
|
+
Contract (plan/architecture.md "Core types"): ``raw`` preserves the input line
|
|
4
|
+
byte-for-byte (minus the trailing newline) so ``filter``/``top_k`` can honor the
|
|
5
|
+
passthrough-fidelity guarantee; ``data`` is set only when the line is a JSON
|
|
6
|
+
*object* (an NDJSON record) — scalars and arrays are just text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Literal, TypeGuard
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from smartpipe.models.base import MediaData
|
|
18
|
+
|
|
19
|
+
__all__ = ["Item", "ItemSource", "describe_source", "item_from_file", "item_from_line"]
|
|
20
|
+
|
|
21
|
+
_BOM = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ItemSource:
|
|
26
|
+
kind: Literal["stdin", "file"]
|
|
27
|
+
name: str # "-" for stdin, else the path as given
|
|
28
|
+
index: int # 0-based line number (stdin) or file ordinal
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Item:
|
|
33
|
+
raw: str # the line/file EXACTLY as read (newline stripped)
|
|
34
|
+
text: str # model-facing content (== raw for lines; extracted text for files)
|
|
35
|
+
data: Mapping[str, object] | None # parsed object if the line was a JSON object
|
|
36
|
+
source: ItemSource
|
|
37
|
+
media: tuple[MediaData, ...] = () # media parts (D32) — text plus any number of figures/clips
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def item_from_line(line: str, index: int) -> Item:
|
|
41
|
+
raw = line.removesuffix("\n").removesuffix("\r")
|
|
42
|
+
if index == 0:
|
|
43
|
+
raw = raw.removeprefix(_BOM)
|
|
44
|
+
data = _sniff_json_object(raw)
|
|
45
|
+
media = _sniff_media(data)
|
|
46
|
+
text = raw
|
|
47
|
+
if media and data is not None:
|
|
48
|
+
# a media-carrying record's text is its "text" field, not the raw JSON
|
|
49
|
+
carried = data.get("text")
|
|
50
|
+
text = carried if isinstance(carried, str) else ""
|
|
51
|
+
return Item(
|
|
52
|
+
raw=raw,
|
|
53
|
+
text=text,
|
|
54
|
+
data=data,
|
|
55
|
+
source=_named_source(data, index),
|
|
56
|
+
media=media,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def item_from_file(text: str, path: str, index: int) -> Item:
|
|
61
|
+
"""A whole file is one item: its extracted text, with no JSON sniffing (a
|
|
62
|
+
document's text isn't an NDJSON line). ``filter``/``top_k`` emit its path."""
|
|
63
|
+
return Item(
|
|
64
|
+
raw=text,
|
|
65
|
+
text=text,
|
|
66
|
+
data=None,
|
|
67
|
+
source=ItemSource(kind="file", name=path, index=index),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _named_source(data: Mapping[str, object] | None, index: int) -> ItemSource:
|
|
72
|
+
# split emits {"source": "call.mp3 §00:10-00:20"} — keep that provenance
|
|
73
|
+
name = data.get("source") if data is not None else None
|
|
74
|
+
return ItemSource(kind="stdin", name=name if isinstance(name, str) else "-", index=index)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sniff_media(data: Mapping[str, object] | None) -> tuple[MediaData, ...]:
|
|
78
|
+
"""``split`` ships media as base64 NDJSON (audio/video slices, figures, and
|
|
79
|
+
multi-part page items) — rebuild the bytes so the next verb can hear or see
|
|
80
|
+
them (D27/D32)."""
|
|
81
|
+
if data is None:
|
|
82
|
+
return ()
|
|
83
|
+
from smartpipe.core.jsontools import as_items, as_record
|
|
84
|
+
|
|
85
|
+
entries = as_items(data.get("parts"))
|
|
86
|
+
if entries is not None:
|
|
87
|
+
return tuple(
|
|
88
|
+
part for entry in entries if (part := _one_media(as_record(entry))) is not None
|
|
89
|
+
)
|
|
90
|
+
single = _one_media(data)
|
|
91
|
+
return (single,) if single is not None else ()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _one_media(data: Mapping[str, object] | None) -> MediaData | None:
|
|
95
|
+
if data is None:
|
|
96
|
+
return None
|
|
97
|
+
mime = data.get("mime")
|
|
98
|
+
if not isinstance(mime, str):
|
|
99
|
+
return None
|
|
100
|
+
import base64
|
|
101
|
+
import binascii
|
|
102
|
+
|
|
103
|
+
from smartpipe.models.base import ( # runtime construction
|
|
104
|
+
AudioData,
|
|
105
|
+
ImageData,
|
|
106
|
+
VideoData,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for key, build in (
|
|
110
|
+
("audio_b64", AudioData),
|
|
111
|
+
("image_b64", ImageData),
|
|
112
|
+
("video_b64", VideoData),
|
|
113
|
+
):
|
|
114
|
+
encoded = data.get(key)
|
|
115
|
+
if not isinstance(encoded, str):
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
return build(base64.b64decode(encoded, validate=True), mime)
|
|
119
|
+
except (binascii.Error, ValueError):
|
|
120
|
+
return None # not ours — treat as a plain JSON line
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def describe_source(source: ItemSource) -> str:
|
|
125
|
+
"""Human wording for warnings — 1-based lines, plain filenames; a split
|
|
126
|
+
stage's provenance (``call.wav §00:10-00:20``) survives the pipe."""
|
|
127
|
+
if source.kind == "stdin" and source.name == "-":
|
|
128
|
+
return f"line {source.index + 1}"
|
|
129
|
+
if source.kind == "stdin":
|
|
130
|
+
return source.name
|
|
131
|
+
return source.name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _sniff_json_object(raw: str) -> Mapping[str, object] | None:
|
|
135
|
+
candidate = raw.lstrip()
|
|
136
|
+
if not candidate.startswith("{"):
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
parsed: object = json.loads(candidate)
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
return None
|
|
142
|
+
if not _is_json_object(parsed): # pragma: no cover — a parsed "{…}" is always an object
|
|
143
|
+
return None
|
|
144
|
+
return dict(parsed)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_json_object(value: object) -> TypeGuard[Mapping[str, object]]:
|
|
148
|
+
"""Sound claim: ``json.loads`` produces ``str`` keys by contract."""
|
|
149
|
+
return isinstance(value, dict)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The live top_k leaderboard (stage-08 §4.3): a K-line block repainted in place.
|
|
2
|
+
|
|
3
|
+
``render_frame`` is pure (goldens pin it); ``LiveBoard`` adds the clock, the
|
|
4
|
+
≤4-repaints/s throttle, and the ANSI cursor-up block rewrite. TTY only — pipe
|
|
5
|
+
mode uses NDJSON snapshots through the ordinary writer instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
__all__ = ["LiveBoard", "render_frame"]
|
|
18
|
+
|
|
19
|
+
_CLEAR_LINE = "\x1b[K"
|
|
20
|
+
_MIN_REPAINT_S = 0.25 # ≤ 4 repaints/s (spec) — dumb full-block rewrite, no partials
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_frame(rows: Sequence[tuple[float, str]], width: int) -> list[str]:
|
|
24
|
+
"""``(score, text)`` rows → display lines, truncated to the terminal width."""
|
|
25
|
+
lines: list[str] = []
|
|
26
|
+
for score, text in rows:
|
|
27
|
+
prefix = f"{score:0.2f} "
|
|
28
|
+
budget = max(width - len(prefix), 8)
|
|
29
|
+
body = text if len(text) <= budget else text[: budget - 1] + "…"
|
|
30
|
+
lines.append(prefix + body)
|
|
31
|
+
return lines
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class LiveBoard:
|
|
36
|
+
stream: TextIO
|
|
37
|
+
width: int
|
|
38
|
+
clock: Callable[[], float]
|
|
39
|
+
_painted: int = 0 # lines currently on screen
|
|
40
|
+
_last: float = field(default=-1.0)
|
|
41
|
+
|
|
42
|
+
def paint(self, rows: Sequence[tuple[float, str]], *, force: bool = False) -> None:
|
|
43
|
+
now = self.clock()
|
|
44
|
+
if not force and now - self._last < _MIN_REPAINT_S:
|
|
45
|
+
return
|
|
46
|
+
self._last = now
|
|
47
|
+
if self._painted:
|
|
48
|
+
self.stream.write(f"\x1b[{self._painted}A") # cursor up over the old block
|
|
49
|
+
for line in render_frame(rows, self.width):
|
|
50
|
+
self.stream.write(f"\r{line}{_CLEAR_LINE}\n")
|
|
51
|
+
self._painted = len(rows)
|
|
52
|
+
self.stream.flush()
|
smartpipe/io/metering.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Run telemetry (D40): observed units, never estimated dollars.
|
|
2
|
+
|
|
3
|
+
A module-level, run-scoped meter — the documented diagnostics-style exception
|
|
4
|
+
to no-globals (one verb per process; ``reset()`` at container build and in
|
|
5
|
+
tests). Numbers come from provider usage fields and real byte counts; when a
|
|
6
|
+
wire omits usage, the meter under-counts rather than lies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import wave
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from smartpipe.models.base import MediaData
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"add_conversion",
|
|
23
|
+
"add_request_media",
|
|
24
|
+
"add_tokens",
|
|
25
|
+
"count",
|
|
26
|
+
"duration",
|
|
27
|
+
"megabytes",
|
|
28
|
+
"receipt",
|
|
29
|
+
"reset",
|
|
30
|
+
"snapshot",
|
|
31
|
+
"status_segment",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class _Meter:
|
|
37
|
+
tokens_in: int = 0
|
|
38
|
+
tokens_out: int = 0
|
|
39
|
+
media_bytes: dict[str, int] = field(default_factory=dict[str, int])
|
|
40
|
+
media_count: dict[str, int] = field(default_factory=dict[str, int])
|
|
41
|
+
audio_seconds: float = 0.0
|
|
42
|
+
conversions: int = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_state = _Meter()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reset() -> None:
|
|
49
|
+
global _state
|
|
50
|
+
_state = _Meter()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add_tokens(*, tokens_in: int = 0, tokens_out: int = 0) -> None:
|
|
54
|
+
_state.tokens_in += max(0, tokens_in)
|
|
55
|
+
_state.tokens_out += max(0, tokens_out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_conversion() -> None:
|
|
59
|
+
"""One PAID conversion (cloud caption/hear/watch/STT) — local whisper is
|
|
60
|
+
free and uncounted."""
|
|
61
|
+
_state.conversions += 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_request_media(parts: Sequence[MediaData]) -> None:
|
|
65
|
+
"""Meter media actually being SENT (call after pre-send refusals — a
|
|
66
|
+
refused part costs nothing)."""
|
|
67
|
+
from smartpipe.models.base import AudioData, ImageData, VideoData
|
|
68
|
+
|
|
69
|
+
for part in parts:
|
|
70
|
+
match part:
|
|
71
|
+
case ImageData():
|
|
72
|
+
kind = "image"
|
|
73
|
+
case AudioData():
|
|
74
|
+
kind = "audio"
|
|
75
|
+
_state.audio_seconds += _wav_seconds(part.data, part.mime) or 0.0
|
|
76
|
+
case VideoData():
|
|
77
|
+
kind = "video"
|
|
78
|
+
case _ as unreachable: # pragma: no cover — the union is closed
|
|
79
|
+
from typing import assert_never
|
|
80
|
+
|
|
81
|
+
assert_never(unreachable)
|
|
82
|
+
_state.media_bytes[kind] = _state.media_bytes.get(kind, 0) + len(part.data)
|
|
83
|
+
_state.media_count[kind] = _state.media_count.get(kind, 0) + 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _wav_seconds(data: bytes, mime: str) -> float | None:
|
|
87
|
+
if mime not in ("audio/wav", "audio/x-wav"):
|
|
88
|
+
return None # other containers need ffprobe; bytes-only is honest enough
|
|
89
|
+
try:
|
|
90
|
+
with wave.open(io.BytesIO(data)) as clip:
|
|
91
|
+
rate = clip.getframerate()
|
|
92
|
+
return clip.getnframes() / rate if rate else None
|
|
93
|
+
except (wave.Error, EOFError, OSError, ValueError, RuntimeError):
|
|
94
|
+
return None # malformed RIFF — bytes-only is honest enough
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Snapshot:
|
|
99
|
+
tokens_in: int
|
|
100
|
+
tokens_out: int
|
|
101
|
+
media_bytes: dict[str, int]
|
|
102
|
+
media_count: dict[str, int]
|
|
103
|
+
audio_seconds: float
|
|
104
|
+
conversions: int
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def empty(self) -> bool:
|
|
108
|
+
return not (self.tokens_in or self.tokens_out or self.media_bytes or self.conversions)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def snapshot() -> Snapshot:
|
|
112
|
+
return Snapshot(
|
|
113
|
+
tokens_in=_state.tokens_in,
|
|
114
|
+
tokens_out=_state.tokens_out,
|
|
115
|
+
media_bytes=dict(_state.media_bytes),
|
|
116
|
+
media_count=dict(_state.media_count),
|
|
117
|
+
audio_seconds=_state.audio_seconds,
|
|
118
|
+
conversions=_state.conversions,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- formatting --------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def count(value: int) -> str:
|
|
126
|
+
if value >= 1_000_000:
|
|
127
|
+
return f"{value / 1_000_000:.1f}M"
|
|
128
|
+
if value >= 1_000:
|
|
129
|
+
return f"{value / 1_000:.1f}k"
|
|
130
|
+
return str(value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def megabytes(size: int) -> str:
|
|
134
|
+
return f"{size / 1_048_576:.1f} MB"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def duration(seconds: float) -> str:
|
|
138
|
+
whole = int(seconds)
|
|
139
|
+
if whole >= 60:
|
|
140
|
+
return f"{whole // 60}m{whole % 60:02d}s"
|
|
141
|
+
return f"{whole}s"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def status_segment() -> str:
|
|
145
|
+
"""The live status-line segment; empty string when nothing was consumed."""
|
|
146
|
+
view = snapshot()
|
|
147
|
+
if view.empty:
|
|
148
|
+
return ""
|
|
149
|
+
pieces = [f"↑{count(view.tokens_in)} ↓{count(view.tokens_out)} tok"]
|
|
150
|
+
for kind, label in (("image", "img"), ("video", "vid")):
|
|
151
|
+
size = view.media_bytes.get(kind)
|
|
152
|
+
if size:
|
|
153
|
+
pieces.append(f"{megabytes(size)} {label}")
|
|
154
|
+
if view.media_bytes.get("audio"):
|
|
155
|
+
held = (
|
|
156
|
+
duration(view.audio_seconds)
|
|
157
|
+
if view.audio_seconds
|
|
158
|
+
else megabytes(view.media_bytes["audio"])
|
|
159
|
+
)
|
|
160
|
+
pieces.append(f"{held} audio")
|
|
161
|
+
return " · ".join(pieces)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def receipt() -> str | None:
|
|
165
|
+
"""The end-of-run totals line — the number that goes in the report."""
|
|
166
|
+
view = snapshot()
|
|
167
|
+
if view.empty:
|
|
168
|
+
return None
|
|
169
|
+
pieces = [f"{count(view.tokens_in)} in · {count(view.tokens_out)} out tokens"]
|
|
170
|
+
for kind, plural in (("image", "images"), ("video", "video")):
|
|
171
|
+
size = view.media_bytes.get(kind)
|
|
172
|
+
if size:
|
|
173
|
+
pieces.append(f"{megabytes(size)} {plural} ({view.media_count[kind]})")
|
|
174
|
+
audio_size = view.media_bytes.get("audio")
|
|
175
|
+
if audio_size:
|
|
176
|
+
timed = f" · {duration(view.audio_seconds)}" if view.audio_seconds else ""
|
|
177
|
+
pieces.append(f"{megabytes(audio_size)} audio ({view.media_count['audio']}){timed}")
|
|
178
|
+
if view.conversions:
|
|
179
|
+
pieces.append(f"{view.conversions} paid conversions")
|
|
180
|
+
return "run: " + " · ".join(pieces)
|