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,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)
@@ -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()
@@ -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)