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/tty.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Terminal detection and the color decision.
|
|
2
|
+
|
|
3
|
+
``supports_color`` is pure — the environment is a parameter — so the whole truth
|
|
4
|
+
table is testable. The thin wrappers read real process state at call time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import TYPE_CHECKING, assert_never
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ColorMode",
|
|
20
|
+
"enable_windows_vt",
|
|
21
|
+
"stderr_is_tty",
|
|
22
|
+
"stderr_supports_color",
|
|
23
|
+
"stdout_is_tty",
|
|
24
|
+
"supports_color",
|
|
25
|
+
"terminal_width",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ColorMode(StrEnum):
|
|
30
|
+
AUTO = "auto"
|
|
31
|
+
ALWAYS = "always"
|
|
32
|
+
NEVER = "never"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stdout_is_tty() -> bool:
|
|
36
|
+
return sys.stdout.isatty()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stderr_is_tty() -> bool:
|
|
40
|
+
return sys.stderr.isatty()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def supports_color(stream_is_tty: bool, *, mode: ColorMode, env: Mapping[str, str]) -> bool:
|
|
44
|
+
match mode:
|
|
45
|
+
case ColorMode.ALWAYS:
|
|
46
|
+
return True
|
|
47
|
+
case ColorMode.NEVER:
|
|
48
|
+
return False
|
|
49
|
+
case ColorMode.AUTO:
|
|
50
|
+
if not stream_is_tty:
|
|
51
|
+
return False
|
|
52
|
+
if "NO_COLOR" in env: # any value disables — https://no-color.org
|
|
53
|
+
return False
|
|
54
|
+
return env.get("TERM") != "dumb"
|
|
55
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
56
|
+
assert_never(unreachable)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stderr_supports_color(mode: ColorMode = ColorMode.AUTO) -> bool:
|
|
60
|
+
return supports_color(stderr_is_tty(), mode=mode, env=os.environ)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stdout_supports_color(mode: ColorMode = ColorMode.AUTO) -> bool:
|
|
64
|
+
return supports_color(stdout_is_tty(), mode=mode, env=os.environ)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def terminal_width(default: int = 80) -> int:
|
|
68
|
+
return shutil.get_terminal_size((default, 24)).columns
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def enable_windows_vt() -> bool:
|
|
72
|
+
"""Best-effort ANSI enablement on Windows consoles; True on other platforms."""
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
import ctypes
|
|
75
|
+
|
|
76
|
+
enable_vt = 0x0004
|
|
77
|
+
kernel32 = ctypes.windll.kernel32
|
|
78
|
+
succeeded = True
|
|
79
|
+
for std_handle in (-11, -12): # stdout, stderr
|
|
80
|
+
handle = kernel32.GetStdHandle(std_handle)
|
|
81
|
+
mode = ctypes.c_ulong()
|
|
82
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
83
|
+
succeeded = False
|
|
84
|
+
continue
|
|
85
|
+
if not kernel32.SetConsoleMode(handle, mode.value | enable_vt):
|
|
86
|
+
succeeded = False
|
|
87
|
+
return succeeded
|
|
88
|
+
return True
|
smartpipe/io/usage.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The usage ledger (D41): what the meter observed, remembered over time.
|
|
2
|
+
|
|
3
|
+
One event per model-touching run, persisted at container exit; windows are
|
|
4
|
+
computed at read time; ``lifetime`` accumulates separately so pruning old
|
|
5
|
+
events loses nothing. Telemetry must never fail a run — every filesystem
|
|
6
|
+
error here is swallowed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from smartpipe.core.jsontools import as_items, as_record
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
from smartpipe.io.metering import Snapshot
|
|
25
|
+
|
|
26
|
+
__all__ = ["Totals", "read_ledger", "record_run", "reset_ledger", "usage_path"]
|
|
27
|
+
|
|
28
|
+
_EVENT_HORIZON_DAYS = 32 # windows top out at 30 days; older events prune
|
|
29
|
+
_FIELDS = ("runs", "tokens_in", "tokens_out", "media_bytes", "audio_seconds", "conversions")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Totals:
|
|
34
|
+
runs: int = 0
|
|
35
|
+
tokens_in: int = 0
|
|
36
|
+
tokens_out: int = 0
|
|
37
|
+
media_bytes: int = 0
|
|
38
|
+
audio_seconds: float = 0.0
|
|
39
|
+
conversions: int = 0
|
|
40
|
+
|
|
41
|
+
def plus(self, other: Totals) -> Totals:
|
|
42
|
+
return Totals(
|
|
43
|
+
runs=self.runs + other.runs,
|
|
44
|
+
tokens_in=self.tokens_in + other.tokens_in,
|
|
45
|
+
tokens_out=self.tokens_out + other.tokens_out,
|
|
46
|
+
media_bytes=self.media_bytes + other.media_bytes,
|
|
47
|
+
audio_seconds=self.audio_seconds + other.audio_seconds,
|
|
48
|
+
conversions=self.conversions + other.conversions,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usage_path(env: Mapping[str, str]) -> Path:
|
|
53
|
+
base = env.get("XDG_STATE_HOME", "").strip()
|
|
54
|
+
root = Path(base) if base else Path.home() / ".local" / "state"
|
|
55
|
+
return root / "smartpipe" / "usage.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record_run(snapshot: Snapshot, env: Mapping[str, str], *, now: float | None = None) -> None:
|
|
59
|
+
"""Persist one run's observed usage. Best-effort: never raises."""
|
|
60
|
+
if snapshot.empty:
|
|
61
|
+
return
|
|
62
|
+
moment = time.time() if now is None else now
|
|
63
|
+
try:
|
|
64
|
+
path = usage_path(env)
|
|
65
|
+
document = _load(path)
|
|
66
|
+
event = {
|
|
67
|
+
"ts": moment,
|
|
68
|
+
"runs": 1,
|
|
69
|
+
"tokens_in": snapshot.tokens_in,
|
|
70
|
+
"tokens_out": snapshot.tokens_out,
|
|
71
|
+
"media_bytes": sum(snapshot.media_bytes.values()),
|
|
72
|
+
"audio_seconds": snapshot.audio_seconds,
|
|
73
|
+
"conversions": snapshot.conversions,
|
|
74
|
+
}
|
|
75
|
+
horizon = moment - _EVENT_HORIZON_DAYS * 86_400
|
|
76
|
+
kept = [entry for entry in _document_events(document) if _event_ts(entry) >= horizon]
|
|
77
|
+
document["events"] = [*kept, event]
|
|
78
|
+
document["lifetime"] = _totals_dict(
|
|
79
|
+
_totals_from(_document_lifetime(document)).plus(_totals_from(event))
|
|
80
|
+
)
|
|
81
|
+
if document.get("first_seen") is None:
|
|
82
|
+
document["first_seen"] = moment
|
|
83
|
+
_store(path, document)
|
|
84
|
+
except OSError:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_ledger(
|
|
89
|
+
env: Mapping[str, str], *, now: float | None = None
|
|
90
|
+
) -> tuple[dict[str, Totals], float | None, float | None]:
|
|
91
|
+
"""(windows, first_seen, last_reset) — windows keyed hour/day/week/month/lifetime."""
|
|
92
|
+
moment = time.time() if now is None else now
|
|
93
|
+
document = _load(usage_path(env))
|
|
94
|
+
events = _document_events(document)
|
|
95
|
+
windows: dict[str, Totals] = {}
|
|
96
|
+
for name, seconds in (
|
|
97
|
+
("past hour", 3_600),
|
|
98
|
+
("past day", 86_400),
|
|
99
|
+
("past week", 7 * 86_400),
|
|
100
|
+
("past month", 30 * 86_400),
|
|
101
|
+
):
|
|
102
|
+
recent = [entry for entry in events if _event_ts(entry) >= moment - seconds]
|
|
103
|
+
totals = Totals()
|
|
104
|
+
for entry in recent:
|
|
105
|
+
totals = totals.plus(_totals_from(entry))
|
|
106
|
+
windows[name] = totals
|
|
107
|
+
windows["lifetime"] = _totals_from(_document_lifetime(document))
|
|
108
|
+
first_seen = document.get("first_seen")
|
|
109
|
+
last_reset = document.get("last_reset")
|
|
110
|
+
return (
|
|
111
|
+
windows,
|
|
112
|
+
first_seen if isinstance(first_seen, (int, float)) else None,
|
|
113
|
+
last_reset if isinstance(last_reset, (int, float)) else None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def reset_ledger(env: Mapping[str, str], *, now: float | None = None) -> Totals:
|
|
118
|
+
"""Zero everything, stamp the reset, return the PREVIOUS lifetime."""
|
|
119
|
+
moment = time.time() if now is None else now
|
|
120
|
+
path = usage_path(env)
|
|
121
|
+
document = _load(path)
|
|
122
|
+
previous = _totals_from(_document_lifetime(document))
|
|
123
|
+
fresh = _empty_document()
|
|
124
|
+
fresh["first_seen"] = document.get("first_seen") or moment
|
|
125
|
+
fresh["last_reset"] = moment
|
|
126
|
+
_store(path, fresh)
|
|
127
|
+
return previous
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def stamp(moment: float) -> str:
|
|
131
|
+
return datetime.fromtimestamp(moment, tz=UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- the file ----------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _empty_document() -> dict[str, object]:
|
|
138
|
+
return {
|
|
139
|
+
"version": 1,
|
|
140
|
+
"first_seen": None,
|
|
141
|
+
"last_reset": None,
|
|
142
|
+
"lifetime": _totals_dict(Totals()),
|
|
143
|
+
"events": [],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load(path: Path) -> dict[str, object]:
|
|
148
|
+
try:
|
|
149
|
+
parsed: object = json.loads(path.read_text(encoding="utf-8"))
|
|
150
|
+
except (OSError, json.JSONDecodeError):
|
|
151
|
+
return _empty_document()
|
|
152
|
+
record = as_record(parsed)
|
|
153
|
+
if record is None:
|
|
154
|
+
return _empty_document()
|
|
155
|
+
document = _empty_document()
|
|
156
|
+
document["first_seen"] = record.get("first_seen")
|
|
157
|
+
document["last_reset"] = record.get("last_reset")
|
|
158
|
+
lifetime = as_record(record.get("lifetime"))
|
|
159
|
+
if lifetime is not None:
|
|
160
|
+
document["lifetime"] = dict(lifetime)
|
|
161
|
+
events = as_items(record.get("events"))
|
|
162
|
+
if events is not None:
|
|
163
|
+
document["events"] = [dict(entry) for item in events if (entry := as_record(item))]
|
|
164
|
+
return document
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _store(path: Path, document: dict[str, object]) -> None:
|
|
168
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
scratch = path.with_suffix(".tmp")
|
|
170
|
+
scratch.write_text(json.dumps(document), encoding="utf-8")
|
|
171
|
+
os.replace(scratch, path)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _document_events(document: dict[str, object]) -> list[dict[str, object]]:
|
|
175
|
+
held = as_items(document.get("events"))
|
|
176
|
+
if held is None:
|
|
177
|
+
return []
|
|
178
|
+
return [dict(entry) for item in held if (entry := as_record(item)) is not None]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _document_lifetime(document: dict[str, object]) -> Mapping[str, object]:
|
|
182
|
+
held = as_record(document.get("lifetime"))
|
|
183
|
+
return held if held is not None else {}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _event_ts(entry: Mapping[str, object]) -> float:
|
|
187
|
+
value = entry.get("ts")
|
|
188
|
+
return float(value) if isinstance(value, (int, float)) else 0.0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _totals_from(entry: Mapping[str, object]) -> Totals:
|
|
192
|
+
def _number(key: str) -> float:
|
|
193
|
+
value = entry.get(key)
|
|
194
|
+
return float(value) if isinstance(value, (int, float)) else 0.0
|
|
195
|
+
|
|
196
|
+
return Totals(
|
|
197
|
+
runs=int(_number("runs")),
|
|
198
|
+
tokens_in=int(_number("tokens_in")),
|
|
199
|
+
tokens_out=int(_number("tokens_out")),
|
|
200
|
+
media_bytes=int(_number("media_bytes")),
|
|
201
|
+
audio_seconds=_number("audio_seconds"),
|
|
202
|
+
conversions=int(_number("conversions")),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _totals_dict(totals: Totals) -> dict[str, object]:
|
|
207
|
+
return {
|
|
208
|
+
"runs": totals.runs,
|
|
209
|
+
"tokens_in": totals.tokens_in,
|
|
210
|
+
"tokens_out": totals.tokens_out,
|
|
211
|
+
"media_bytes": totals.media_bytes,
|
|
212
|
+
"audio_seconds": totals.audio_seconds,
|
|
213
|
+
"conversions": totals.conversions,
|
|
214
|
+
}
|
smartpipe/io/writers.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""TTY-adaptive result writers — the only module that writes to stdout.
|
|
2
|
+
|
|
3
|
+
Two vocabularies on purpose: ``OutputFormat`` is what users say (``--output`` /
|
|
4
|
+
``SMARTPIPE_OUTPUT``); ``RenderMode`` is what a writer does. ``resolve_format``
|
|
5
|
+
maps one to the other using the TTY matrix in plan/ux.md — notably, AUTO on a
|
|
6
|
+
terminal renders structured results as a human view, while an *explicit*
|
|
7
|
+
``--output json`` forces NDJSON even there (spec §5.2).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol, assert_never
|
|
18
|
+
|
|
19
|
+
from smartpipe.core.errors import UsageFault
|
|
20
|
+
from smartpipe.core.jsontools import as_record
|
|
21
|
+
from smartpipe.io import diagnostics
|
|
22
|
+
from smartpipe.io.text import clip_to_width, display_width
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from typing import TextIO
|
|
26
|
+
|
|
27
|
+
from smartpipe.io.items import Item
|
|
28
|
+
|
|
29
|
+
_TRAILING_COLUMNS = ("_score", "_rank") # ranking metadata sorts to the right of the sheet
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"OutputFormat",
|
|
33
|
+
"RenderMode",
|
|
34
|
+
"ResultWriter",
|
|
35
|
+
"WriterConfig",
|
|
36
|
+
"make_writer",
|
|
37
|
+
"resolve_format",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_DIM = "\x1b[2m"
|
|
41
|
+
_RESET = "\x1b[0m"
|
|
42
|
+
_ELLIPSIS = "…"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OutputFormat(StrEnum):
|
|
46
|
+
AUTO = "auto"
|
|
47
|
+
TEXT = "text"
|
|
48
|
+
JSON = "json"
|
|
49
|
+
CSV = "csv"
|
|
50
|
+
TSV = "tsv"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RenderMode(StrEnum):
|
|
54
|
+
TEXT = "text"
|
|
55
|
+
NDJSON = "ndjson"
|
|
56
|
+
HUMAN = "human"
|
|
57
|
+
CSV = "csv"
|
|
58
|
+
TSV = "tsv"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class WriterConfig:
|
|
63
|
+
mode: RenderMode
|
|
64
|
+
color: bool
|
|
65
|
+
width: int
|
|
66
|
+
fields: tuple[str, ...] | None = None # honored from stage 9
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ResultWriter(Protocol):
|
|
70
|
+
def write_text(self, line: str) -> None: ...
|
|
71
|
+
def write_record(self, record: Mapping[str, object]) -> None: ...
|
|
72
|
+
def write_passthrough(self, item: Item) -> None: ...
|
|
73
|
+
def flush(self) -> None: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_format(
|
|
77
|
+
flag: OutputFormat,
|
|
78
|
+
env: Mapping[str, str],
|
|
79
|
+
*,
|
|
80
|
+
stdout_tty: bool,
|
|
81
|
+
structured: bool,
|
|
82
|
+
fields: tuple[str, ...] | None = None,
|
|
83
|
+
) -> RenderMode:
|
|
84
|
+
if fields is not None and not structured:
|
|
85
|
+
raise UsageFault(
|
|
86
|
+
"--fields selects columns from structured output\n"
|
|
87
|
+
" This run produces plain text — there are no named fields to pick from.\n"
|
|
88
|
+
' Add braces to the prompt (e.g. "Extract {name, email}") or pass --schema.'
|
|
89
|
+
)
|
|
90
|
+
requested = flag
|
|
91
|
+
if requested is OutputFormat.AUTO:
|
|
92
|
+
env_value = env.get("SMARTPIPE_OUTPUT", "")
|
|
93
|
+
if env_value:
|
|
94
|
+
try:
|
|
95
|
+
requested = OutputFormat(env_value)
|
|
96
|
+
except ValueError:
|
|
97
|
+
raise UsageFault(
|
|
98
|
+
f"SMARTPIPE_OUTPUT={env_value!r} isn't a format smartpipe knows\n"
|
|
99
|
+
" valid values: auto, text, json, csv, tsv"
|
|
100
|
+
) from None
|
|
101
|
+
match requested:
|
|
102
|
+
case OutputFormat.AUTO:
|
|
103
|
+
if structured:
|
|
104
|
+
return RenderMode.HUMAN if stdout_tty else RenderMode.NDJSON
|
|
105
|
+
return RenderMode.TEXT
|
|
106
|
+
case OutputFormat.TEXT:
|
|
107
|
+
return RenderMode.TEXT
|
|
108
|
+
case OutputFormat.JSON:
|
|
109
|
+
return RenderMode.NDJSON
|
|
110
|
+
case OutputFormat.CSV:
|
|
111
|
+
_require_structured(requested, structured=structured)
|
|
112
|
+
return RenderMode.CSV
|
|
113
|
+
case OutputFormat.TSV:
|
|
114
|
+
_require_structured(requested, structured=structured)
|
|
115
|
+
return RenderMode.TSV
|
|
116
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
117
|
+
assert_never(unreachable)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _require_structured(fmt: OutputFormat, *, structured: bool) -> None:
|
|
121
|
+
if not structured:
|
|
122
|
+
raise UsageFault(
|
|
123
|
+
f"--output {fmt.value} needs structured output — a table needs named columns\n"
|
|
124
|
+
' add braces to the prompt (e.g. "Extract {name, email}") or pass --schema'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_writer(config: WriterConfig, stdout: TextIO) -> ResultWriter:
|
|
129
|
+
match config.mode:
|
|
130
|
+
case RenderMode.TEXT:
|
|
131
|
+
return _TextWriter(stream=stdout, fields=config.fields)
|
|
132
|
+
case RenderMode.NDJSON:
|
|
133
|
+
return _NdjsonWriter(stream=stdout, fields=config.fields)
|
|
134
|
+
case RenderMode.HUMAN:
|
|
135
|
+
return _HumanWriter(
|
|
136
|
+
stream=stdout, color=config.color, width=config.width, fields=config.fields
|
|
137
|
+
)
|
|
138
|
+
case RenderMode.CSV:
|
|
139
|
+
return _TableWriter(stream=stdout, delimiter=",", fields=config.fields)
|
|
140
|
+
case RenderMode.TSV:
|
|
141
|
+
return _TableWriter(stream=stdout, delimiter="\t", fields=config.fields)
|
|
142
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
143
|
+
assert_never(unreachable)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _compact_json(value: object) -> str:
|
|
147
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_ABSENT = object() # sentinel: "no such field", distinct from a genuine null
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _lookup(record: Mapping[str, object], name: str) -> object:
|
|
154
|
+
"""Exact key first; else a dotted walk into nested objects (join's left.x/right.x)."""
|
|
155
|
+
if name in record:
|
|
156
|
+
return record[name]
|
|
157
|
+
current: object = record
|
|
158
|
+
for part in name.split("."):
|
|
159
|
+
narrowed = as_record(current)
|
|
160
|
+
if narrowed is None or part not in narrowed:
|
|
161
|
+
return _ABSENT
|
|
162
|
+
current = narrowed[part]
|
|
163
|
+
return current
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _warn_missing(record: Mapping[str, object], fields: tuple[str, ...], warned: set[str]) -> None:
|
|
167
|
+
"""The one-time heads-up per requested-but-absent field (plan/ux.md, --fields)."""
|
|
168
|
+
for name in fields:
|
|
169
|
+
if _lookup(record, name) is _ABSENT and name not in warned:
|
|
170
|
+
diagnostics.warn(f"--fields: no field {name!r} in the results; emitting null")
|
|
171
|
+
warned.add(name)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _project(
|
|
175
|
+
record: Mapping[str, object], fields: tuple[str, ...], warned: set[str]
|
|
176
|
+
) -> dict[str, object]:
|
|
177
|
+
"""Select + order the requested columns; absent ones become null (shape stays stable)."""
|
|
178
|
+
_warn_missing(record, fields, warned)
|
|
179
|
+
projected = {name: _lookup(record, name) for name in fields}
|
|
180
|
+
return {name: (None if value is _ABSENT else value) for name, value in projected.items()}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class _TextWriter:
|
|
185
|
+
stream: TextIO
|
|
186
|
+
fields: tuple[str, ...] | None = None # top_k routes structured records through TEXT
|
|
187
|
+
warned: set[str] = field(default_factory=set[str])
|
|
188
|
+
|
|
189
|
+
def write_text(self, line: str) -> None:
|
|
190
|
+
self.stream.write(f"{line}\n")
|
|
191
|
+
self.stream.flush()
|
|
192
|
+
|
|
193
|
+
def write_record(self, record: Mapping[str, object]) -> None:
|
|
194
|
+
if self.fields is not None:
|
|
195
|
+
record = _project(record, self.fields, self.warned)
|
|
196
|
+
self.write_text(_compact_json(dict(record)))
|
|
197
|
+
|
|
198
|
+
def write_passthrough(self, item: Item) -> None:
|
|
199
|
+
self.write_text(item.raw)
|
|
200
|
+
|
|
201
|
+
def flush(self) -> None:
|
|
202
|
+
self.stream.flush()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class _NdjsonWriter:
|
|
207
|
+
stream: TextIO
|
|
208
|
+
fields: tuple[str, ...] | None = None
|
|
209
|
+
warned: set[str] = field(default_factory=set[str])
|
|
210
|
+
|
|
211
|
+
def write_text(self, line: str) -> None:
|
|
212
|
+
self.write_record({"result": line})
|
|
213
|
+
|
|
214
|
+
def write_record(self, record: Mapping[str, object]) -> None:
|
|
215
|
+
if self.fields is not None:
|
|
216
|
+
record = _project(record, self.fields, self.warned)
|
|
217
|
+
self.stream.write(f"{_compact_json(dict(record))}\n")
|
|
218
|
+
self.stream.flush()
|
|
219
|
+
|
|
220
|
+
def write_passthrough(self, item: Item) -> None:
|
|
221
|
+
self.stream.write(f"{item.raw}\n")
|
|
222
|
+
self.stream.flush()
|
|
223
|
+
|
|
224
|
+
def flush(self) -> None:
|
|
225
|
+
self.stream.flush()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _TableWriter:
|
|
229
|
+
"""CSV/TSV — a rectangle is the contract. Columns are fixed by ``--fields`` or the
|
|
230
|
+
first record; later records fill missing cells empty and drop surprise keys with a
|
|
231
|
+
one-time warning. Nested values become compact JSON; TSV strips tabs/newlines."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, *, stream: TextIO, delimiter: str, fields: tuple[str, ...] | None) -> None:
|
|
234
|
+
self.stream = stream
|
|
235
|
+
self.delimiter = delimiter
|
|
236
|
+
self.fields = fields
|
|
237
|
+
self.columns: tuple[str, ...] | None = None
|
|
238
|
+
self.warned: set[str] = set()
|
|
239
|
+
self.tsv_cleaned = False
|
|
240
|
+
# excel dialect gives RFC 4180 quoting + CRLF; TSV mirrors it with a tab delimiter
|
|
241
|
+
self.csv = csv.writer(stream, dialect="excel", delimiter=delimiter)
|
|
242
|
+
|
|
243
|
+
def write_text(self, line: str) -> None:
|
|
244
|
+
# csv is guarded to structured output, but stay valid if a plain result slips in
|
|
245
|
+
self.write_record({"result": line})
|
|
246
|
+
|
|
247
|
+
def write_record(self, record: Mapping[str, object]) -> None:
|
|
248
|
+
if self.columns is None:
|
|
249
|
+
self.columns = self._header(record)
|
|
250
|
+
self.csv.writerow(self.columns)
|
|
251
|
+
if self.fields is not None:
|
|
252
|
+
# explicit projection: dropping extras is the point, absence gets the shared warning
|
|
253
|
+
_warn_missing(record, self.fields, self.warned)
|
|
254
|
+
else:
|
|
255
|
+
for key in record:
|
|
256
|
+
if key not in self.columns and key not in self.warned:
|
|
257
|
+
diagnostics.warn(
|
|
258
|
+
f"column {key!r} appeared after the header was fixed; "
|
|
259
|
+
"use --fields to pin columns"
|
|
260
|
+
)
|
|
261
|
+
self.warned.add(key)
|
|
262
|
+
cells = (_lookup(record, column) for column in self.columns)
|
|
263
|
+
self.csv.writerow([self._cell(None if value is _ABSENT else value) for value in cells])
|
|
264
|
+
self.stream.flush()
|
|
265
|
+
|
|
266
|
+
def write_passthrough(self, item: Item) -> None: # pragma: no cover — csv is structured-only
|
|
267
|
+
self.write_text(item.raw)
|
|
268
|
+
|
|
269
|
+
def flush(self) -> None:
|
|
270
|
+
self.stream.flush()
|
|
271
|
+
|
|
272
|
+
def _header(self, record: Mapping[str, object]) -> tuple[str, ...]:
|
|
273
|
+
if self.fields is not None:
|
|
274
|
+
return self.fields
|
|
275
|
+
body = [key for key in record if key not in _TRAILING_COLUMNS]
|
|
276
|
+
trailing = [key for key in _TRAILING_COLUMNS if key in record]
|
|
277
|
+
return (*body, *trailing)
|
|
278
|
+
|
|
279
|
+
def _cell(self, value: object) -> str:
|
|
280
|
+
text = _scalar(value)
|
|
281
|
+
if self.delimiter == "\t" and any(ch in text for ch in "\t\n\r"):
|
|
282
|
+
if not self.tsv_cleaned:
|
|
283
|
+
diagnostics.warn("replaced tabs/newlines in TSV cells with spaces")
|
|
284
|
+
self.tsv_cleaned = True
|
|
285
|
+
text = text.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
|
286
|
+
return text
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _scalar(value: object) -> str:
|
|
290
|
+
if value is None:
|
|
291
|
+
return ""
|
|
292
|
+
if isinstance(value, str):
|
|
293
|
+
return value
|
|
294
|
+
if isinstance(value, bool):
|
|
295
|
+
return "true" if value else "false"
|
|
296
|
+
if isinstance(value, int | float):
|
|
297
|
+
return str(value)
|
|
298
|
+
return _compact_json(value) # objects/arrays → compact JSON in one cell
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, slots=True)
|
|
302
|
+
class _HumanWriter:
|
|
303
|
+
"""Structured results as aligned key/value blocks — TTY reading, never parsing.
|
|
304
|
+
|
|
305
|
+
Truncation to the terminal width happens here and only here: piped output
|
|
306
|
+
(the other writers) is never truncated (spec §5.1).
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
stream: TextIO
|
|
310
|
+
color: bool
|
|
311
|
+
width: int
|
|
312
|
+
fields: tuple[str, ...] | None = None
|
|
313
|
+
warned: set[str] = field(default_factory=set[str])
|
|
314
|
+
|
|
315
|
+
def write_text(self, line: str) -> None:
|
|
316
|
+
self.stream.write(f"{line}\n")
|
|
317
|
+
self.stream.flush()
|
|
318
|
+
|
|
319
|
+
def write_record(self, record: Mapping[str, object]) -> None:
|
|
320
|
+
if self.fields is not None:
|
|
321
|
+
record = _project(record, self.fields, self.warned)
|
|
322
|
+
for key, value in record.items():
|
|
323
|
+
# null shows as nothing — for humans an absent value reads best as blank
|
|
324
|
+
rendered = (
|
|
325
|
+
"" if value is None else value if isinstance(value, str) else _compact_json(value)
|
|
326
|
+
)
|
|
327
|
+
# budget in terminal cells, not code points (DEFER-2) — a Wide char is 2
|
|
328
|
+
available = self.width - display_width(key) - 2
|
|
329
|
+
if available >= 2 and display_width(rendered) > available:
|
|
330
|
+
rendered = clip_to_width(rendered, available - 1) + _ELLIPSIS
|
|
331
|
+
label = f"{_DIM}{key}:{_RESET}" if self.color else f"{key}:"
|
|
332
|
+
self.stream.write(f"{label} {rendered}\n")
|
|
333
|
+
self.stream.write("\n")
|
|
334
|
+
self.stream.flush()
|
|
335
|
+
|
|
336
|
+
def write_passthrough(self, item: Item) -> None:
|
|
337
|
+
self.write_text(item.raw)
|
|
338
|
+
|
|
339
|
+
def flush(self) -> None:
|
|
340
|
+
self.stream.flush()
|