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
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
+ }
@@ -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()
@@ -0,0 +1,5 @@
1
+ """Model providers: the ChatModel/EmbeddingModel protocols and their adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []