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,231 @@
1
+ """Multi-line UX screens, verbatim from plan/ux.md (golden-pinned in tests).
2
+
3
+ Style contract (plan/ux.md): every screen contains its own fix — no error may
4
+ require opening a browser or reading docs to resolve.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "BINARY_STDIN_UNPARSEABLE",
11
+ "CHATGPT_LOGIN_EXPIRED",
12
+ "EMBEDDINGS_NEED_KEY",
13
+ "FIELD_REF_ON_PLAIN_INPUT",
14
+ "NO_MODEL",
15
+ "WELCOME",
16
+ "cloud_model_missing",
17
+ "missing_anthropic_extra",
18
+ "missing_api_key",
19
+ "ollama_model_missing",
20
+ "ollama_unreachable",
21
+ "openai_needs_key_or_login",
22
+ "schema_rejected",
23
+ "stdin_document_failed",
24
+ ]
25
+
26
+ CHATGPT_LOGIN_EXPIRED = """\
27
+ error: the ChatGPT login has expired and couldn't be refreshed
28
+ Fix: smartpipe auth login"""
29
+
30
+ EMBEDDINGS_NEED_KEY = """\
31
+ error: embeddings aren't available through ChatGPT login
32
+ The ChatGPT plan wire serves chat models only.
33
+ Fix: export OPENAI_API_KEY=sk-...
34
+ or use a local model: smartpipe config embed-model nomic-embed-text"""
35
+
36
+
37
+ def openai_needs_key_or_login(model: str) -> str:
38
+ return (
39
+ f"error: model '{model}' needs an OpenAI API key or a ChatGPT login\n"
40
+ " smartpipe found no OPENAI_API_KEY and no ChatGPT login. Keys are never\n"
41
+ " stored in config.\n"
42
+ " Fix: export OPENAI_API_KEY=sk-... (platform billing)\n"
43
+ " or: smartpipe auth login (use your ChatGPT Plus/Pro plan)"
44
+ )
45
+
46
+
47
+ BINARY_STDIN_UNPARSEABLE = """\
48
+ error: stdin looks like binary data smartpipe can't parse
49
+ Recognized on stdin: text lines, or a single PDF/DOCX/PPTX/XLSX/audio/image document.
50
+ For files on disk use --in: smartpipe map "Summarize" --in 'report.pdf'"""
51
+
52
+
53
+ def stdin_document_failed(reason: str) -> str:
54
+ return (
55
+ f"error: stdin looks like a document, but it couldn't be read ({reason})\n"
56
+ " smartpipe reads ONE binary document per run from stdin.\n"
57
+ " Alternative: smartpipe map \"…\" --in 'report.pdf'"
58
+ )
59
+
60
+
61
+ FIELD_REF_ON_PLAIN_INPUT = """\
62
+ error: the prompt references a {field}, but the first input line isn't JSON
63
+ {field} substitution needs JSON Lines input (one object per line).
64
+ Either drop the braces, or feed JSONL — e.g.: cat tickets.jsonl | smartpipe filter ..."""
65
+
66
+
67
+ def tint(text: str, code: str) -> str:
68
+ """Style stdout REPORT text (headers, marks): TTY-only, NO_COLOR wins.
69
+ click.echo also strips ANSI when piped, so goldens stay plain (D42)."""
70
+ import os
71
+ import sys
72
+
73
+ if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
74
+ return text
75
+ return f"\x1b[{code}m{text}\x1b[0m"
76
+
77
+
78
+ def heading(text: str) -> str:
79
+ return tint(text, "1;36") # bold cyan — every section/column title, one voice
80
+
81
+
82
+ def good(text: str) -> str:
83
+ return tint(text, "32")
84
+
85
+
86
+ def bad(text: str) -> str:
87
+ return tint(text, "31")
88
+
89
+
90
+ def _c(text: str, code: str) -> str:
91
+ """Bake ANSI into the welcome — click.echo strips it when piped, and the
92
+ NO_COLOR gate lives at the echo site (D42)."""
93
+ return f"\x1b[{code}m{text}\x1b[0m"
94
+
95
+
96
+ _WORDMARK = r"""
97
+ _ _
98
+ ___ _ __ __ _ _ _ | |_ _ __ (_) _ __ ___
99
+ (_-<| ' \ / _` || '_|| _|| '_ \| || '_ \/ -_)
100
+ /__/|_|_|_|\__,_||_| \__|| .__/|_|| .__/\___|
101
+ |_| |_|"""
102
+
103
+ _VERBS: tuple[tuple[str, str], ...] = (
104
+ ("map", "Transform each item with a prompt"),
105
+ ("extend", "Add extracted fields to each record"),
106
+ ("filter", "Keep items matching a semantic condition"),
107
+ ("embed", "Convert items to vector embeddings"),
108
+ ("top_k", "Rank items by similarity to a query"),
109
+ ("reduce", "Synthesize many items into one"),
110
+ ("join", "Match stdin against a second input, semantically"),
111
+ ("cluster", "Group items by meaning; label each group"),
112
+ ("diff", "What distinguishes two sets of items"),
113
+ ("distinct", "Fold near-duplicate items (embeddings only)"),
114
+ ("outliers", "Rank the items least like the rest (embeddings only)"),
115
+ )
116
+
117
+ _UTILITIES: tuple[tuple[str, str], ...] = (
118
+ ("where", "Keep rows matching a deterministic predicate"),
119
+ ("summarize", "Aggregate records: count/avg/percentiles by field"),
120
+ ("sample", "Keep N random rows (seeded, reproducible)"),
121
+ ("getschema", "Report the stream's fields, types, coverage"),
122
+ ("sort", "Order records by a field (numbers, then strings)"),
123
+ ("split", "Break oversized items into chunks"),
124
+ ("chart", "Draw a bar chart of results (--save writes SVG)"),
125
+ ("config", "Configure models and settings"),
126
+ )
127
+
128
+ _NAME_WIDTH = max(len(name) for name, _ in (*_VERBS, *_UTILITIES)) + 2
129
+
130
+
131
+ def _rows(entries: tuple[tuple[str, str], ...]) -> str:
132
+ return "\n".join(f" {_c(name.ljust(_NAME_WIDTH), '32')}{text}" for name, text in entries)
133
+
134
+
135
+ _GET_STARTED = (
136
+ f" smartpipe config{' ' * 37}{_c('# one-minute interactive setup', '2')}\n"
137
+ f' echo "hello" | smartpipe map "translate to Spanish"'
138
+ )
139
+
140
+ WELCOME = f"""\
141
+ {_c(_WORDMARK.lstrip(chr(10)), "36")}
142
+ {_c("smartpipe", "1")} — semantic pipes for your terminal
143
+ {_c("PDFs, images, audio, video, and text — verbs that understand their input.", "2")}
144
+
145
+ {_c("Verbs (call a model):", "1;36")}
146
+ {_rows(_VERBS)}
147
+
148
+ {_c("Utilities (free — no model calls):", "1;36")}
149
+ {_rows(_UTILITIES)}
150
+
151
+ {_c("Get started:", "1;36")}
152
+ {_GET_STARTED}
153
+
154
+ 'smartpipe <command> --help' shows examples for each command.
155
+ """
156
+
157
+ NO_MODEL = """\
158
+ error: no model configured, and no local Ollama found
159
+
160
+ Local (free, private):
161
+ 1. Install Ollama https://ollama.com
162
+ 2. ollama pull qwen3:8b
163
+ 3. smartpipe config model ollama/qwen3:8b
164
+
165
+ Cloud (paid):
166
+ smartpipe config model claude-opus-4-8 then: export ANTHROPIC_API_KEY=sk-ant-...
167
+ smartpipe config model gpt-5.4-mini then: export OPENAI_API_KEY=sk-...
168
+
169
+ Then rerun your command. 'smartpipe config' walks you through this interactively."""
170
+
171
+
172
+ def ollama_unreachable(host: str, model: str, reason: str) -> str:
173
+ return (
174
+ f"error: can't reach ollama at {host} ({reason})\n"
175
+ f" The model '{model}' is configured, but nothing is listening there.\n"
176
+ " Start it with: ollama serve (or check OLLAMA_HOST if it runs elsewhere)"
177
+ )
178
+
179
+
180
+ def ollama_model_missing(name: str, host: str, detail: str) -> str:
181
+ return (
182
+ f"error: ollama doesn't have the model '{name}'\n"
183
+ f" ({host} answered: {detail})\n"
184
+ f" Fix: ollama pull {name} (or check the name with: ollama list)"
185
+ )
186
+
187
+
188
+ def missing_api_key(
189
+ model: str,
190
+ provider: str,
191
+ env_var: str,
192
+ key_shape: str,
193
+ note: str = "add it to your shell profile to persist",
194
+ ) -> str:
195
+ return (
196
+ f"error: model '{model}' needs {_an(provider)} {provider} API key\n"
197
+ f" smartpipe found no {env_var} in the environment. Keys are never stored in config.\n"
198
+ f" Fix: export {env_var}={key_shape} ({note})"
199
+ )
200
+
201
+
202
+ def cloud_model_missing(model: str, host: str) -> str:
203
+ """D18: a 404 for the model dooms every item identically — stop at the first."""
204
+ return (
205
+ f"error: the endpoint doesn't know the model '{model}'\n"
206
+ f" {host} answered 404 — every item would fail identically, "
207
+ "so smartpipe stopped at the first.\n"
208
+ " Fix: check the name, or set one that exists: smartpipe config model gpt-5.4-mini"
209
+ )
210
+
211
+
212
+ def schema_rejected(host: str, detail: str) -> str:
213
+ """D18: a schema the endpoint rejects dooms every item identically."""
214
+ return (
215
+ "error: the endpoint rejected the --schema\n"
216
+ f" {host} answered 400 (response_format): {detail}\n"
217
+ " Fix: simplify the schema — or drop --schema and validate downstream."
218
+ )
219
+
220
+
221
+ def missing_anthropic_extra(model: str) -> str:
222
+ return (
223
+ f"error: the SDK for '{model}' is unavailable\n"
224
+ " Claude models talk through the official anthropic SDK, which ships\n"
225
+ " with smartpipe — a broken environment is the only way here.\n"
226
+ " Fix: reinstall smartpipe"
227
+ )
228
+
229
+
230
+ def _an(word: str) -> str:
231
+ return "an" if word[:1].lower() in "aeiou" else "a"
@@ -0,0 +1,453 @@
1
+ """Translate a ``.sem`` stage file into the verb argv it stands for (D17).
2
+
3
+ A ``.sem`` file is TOML pinning exactly one verb invocation; the shebang line
4
+ ``#!/usr/bin/env -S smartpipe run`` is legal because ``#`` opens a TOML comment.
5
+ The format is a public contract from day one, so validation is exhaustive and
6
+ loud: unknown keys are *errors* (scripts run unattended — the opposite trade
7
+ from config.toml's forward-compat ignore), types are checked with the offending
8
+ line echoed back, and ``run``/``config`` can never be scripted.
9
+
10
+ Pure translation — no click, no I/O beyond reading the file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import tomllib
17
+ from dataclasses import dataclass
18
+ from itertools import chain
19
+ from typing import TYPE_CHECKING
20
+
21
+ from smartpipe.core.errors import UsageFault
22
+ from smartpipe.core.jsontools import as_items
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable, Mapping
26
+ from pathlib import Path
27
+
28
+ __all__ = ["Stage", "parse_pipeline", "parse_sem"]
29
+
30
+ _VERB_NAMES = (
31
+ "map, extend, filter, where, embed, top_k, reduce, join, split, "
32
+ "distinct, outliers, cluster, diff, summarize, sample, getschema, sort, chart"
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class _KeySpec:
38
+ expected: str # names the type in the wrong-type screen
39
+ accepts: Callable[[object], bool]
40
+ render: Callable[[object, Path], tuple[str, ...]]
41
+
42
+
43
+ # --- type checks (TOML value space) -------------------------------------------------
44
+
45
+
46
+ def _is_str(value: object) -> bool:
47
+ return isinstance(value, str)
48
+
49
+
50
+ def _is_int(value: object) -> bool:
51
+ return isinstance(value, int) and not isinstance(value, bool)
52
+
53
+
54
+ def _is_number(value: object) -> bool: # `threshold = 1` is fine; demanding 1.0 is pedantry
55
+ return isinstance(value, int | float) and not isinstance(value, bool)
56
+
57
+
58
+ def _is_bool(value: object) -> bool:
59
+ return isinstance(value, bool)
60
+
61
+
62
+ def _is_str_list(value: object) -> bool:
63
+ entries = as_items(value)
64
+ return entries is not None and all(isinstance(entry, str) for entry in entries)
65
+
66
+
67
+ def _str_entries(value: object) -> tuple[str, ...]:
68
+ entries = as_items(value)
69
+ assert entries is not None # accepts() proved it before render runs
70
+ return tuple(entry for entry in entries if isinstance(entry, str))
71
+
72
+
73
+ # --- renderers: TOML value → argv chunk ----------------------------------------------
74
+
75
+
76
+ def _positional(value: object, _sem_dir: Path) -> tuple[str, ...]:
77
+ return (str(value),)
78
+
79
+
80
+ def _flag(name: str) -> Callable[[object, Path], tuple[str, ...]]:
81
+ def render(value: object, _sem_dir: Path) -> tuple[str, ...]:
82
+ return (name, str(value))
83
+
84
+ return render
85
+
86
+
87
+ def _switch(name: str) -> Callable[[object, Path], tuple[str, ...]]:
88
+ def render(value: object, _sem_dir: Path) -> tuple[str, ...]:
89
+ return (name,) if value else ()
90
+
91
+ return render
92
+
93
+
94
+ def _fields_arg(value: object, _sem_dir: Path) -> tuple[str, ...]:
95
+ return ("--fields", ",".join(_str_entries(value)))
96
+
97
+
98
+ def _globs(value: object, _sem_dir: Path) -> tuple[str, ...]:
99
+ # globs resolve against the CWD, exactly like typing --in at the shell
100
+ return tuple(chain.from_iterable(("--in", pattern) for pattern in _str_entries(value)))
101
+
102
+
103
+ def _schema(value: object, sem_dir: Path) -> tuple[str, ...]:
104
+ assert isinstance(value, str) # a script and its schema travel together
105
+ return ("--schema", str((sem_dir / value).resolve()))
106
+
107
+
108
+ def _prompt_file(value: object, sem_dir: Path) -> tuple[str, ...]:
109
+ assert isinstance(value, str) # a script and its prompt travel together (D23)
110
+ return ("--prompt-file", str((sem_dir / value).resolve()))
111
+
112
+
113
+ def _right(value: object, sem_dir: Path) -> tuple[str, ...]:
114
+ assert isinstance(value, str) # a script and its right side travel together (D21)
115
+ return ("--right", str((sem_dir / value).resolve()))
116
+
117
+
118
+ def _str_key(render: Callable[[object, Path], tuple[str, ...]]) -> _KeySpec:
119
+ return _KeySpec("a string", _is_str, render)
120
+
121
+
122
+ def _int_key(render: Callable[[object, Path], tuple[str, ...]]) -> _KeySpec:
123
+ return _KeySpec("a whole number", _is_int, render)
124
+
125
+
126
+ def _num_key(render: Callable[[object, Path], tuple[str, ...]]) -> _KeySpec:
127
+ return _KeySpec("a number", _is_number, render)
128
+
129
+
130
+ def _bool_key(render: Callable[[object, Path], tuple[str, ...]]) -> _KeySpec:
131
+ return _KeySpec("true or false", _is_bool, render)
132
+
133
+
134
+ def _list_key(render: Callable[[object, Path], tuple[str, ...]]) -> _KeySpec:
135
+ return _KeySpec("an array of strings", _is_str_list, render)
136
+
137
+
138
+ # --- the per-verb key tables (emit order is the tuple order; a public contract) -------
139
+
140
+ _COMMON_TAIL: tuple[tuple[str, _KeySpec], ...] = (
141
+ ("concurrency", _int_key(_flag("--concurrency"))),
142
+ ("max-calls", _int_key(_flag("--max-calls"))),
143
+ ("in", _list_key(_globs)),
144
+ ("from-files", _bool_key(_switch("--from-files"))),
145
+ )
146
+
147
+ _VERB_KEYS: Mapping[str, tuple[tuple[str, _KeySpec], ...]] = {
148
+ "map": (
149
+ ("prompt", _str_key(_positional)),
150
+ ("prompt-file", _str_key(_prompt_file)),
151
+ ("model", _str_key(_flag("--model"))),
152
+ ("output", _str_key(_flag("--output"))),
153
+ ("fields", _list_key(_fields_arg)),
154
+ ("schema-file", _str_key(_schema)),
155
+ ("schema-from", _str_key(_flag("--schema-from"))),
156
+ *_COMMON_TAIL,
157
+ ),
158
+ "filter": (
159
+ ("prompt", _str_key(_positional)),
160
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
161
+ ("prompt-file", _str_key(_prompt_file)),
162
+ ("model", _str_key(_flag("--model"))),
163
+ ("not", _bool_key(_switch("--not"))),
164
+ *_COMMON_TAIL,
165
+ ),
166
+ "embed": (
167
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
168
+ ("embed-model", _str_key(_flag("--embed-model"))),
169
+ ("fields", _list_key(_fields_arg)),
170
+ *_COMMON_TAIL,
171
+ ),
172
+ "top_k": (
173
+ ("k", _int_key(_positional)),
174
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
175
+ ("near", _str_key(_flag("--near"))),
176
+ ("threshold", _num_key(_flag("--threshold"))),
177
+ ("embed-model", _str_key(_flag("--embed-model"))),
178
+ ("fields", _list_key(_fields_arg)),
179
+ ("stream", _bool_key(_switch("--stream"))),
180
+ *_COMMON_TAIL,
181
+ ),
182
+ "split": ( # no model and no concurrency — split never calls one
183
+ ("by", _str_key(_flag("--by"))),
184
+ ("media", _bool_key(_switch("--media"))),
185
+ ("max-tokens", _int_key(_flag("--max-tokens"))),
186
+ ("in", _list_key(_globs)),
187
+ ("from-files", _bool_key(_switch("--from-files"))),
188
+ ),
189
+ "join": (
190
+ ("prompt", _str_key(_positional)),
191
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
192
+ ("prompt-file", _str_key(_prompt_file)),
193
+ ("right", _str_key(_right)),
194
+ ("k", _int_key(_flag("--k"))),
195
+ ("threshold", _num_key(_flag("--threshold"))),
196
+ ("model", _str_key(_flag("--model"))),
197
+ ("embed-model", _str_key(_flag("--embed-model"))),
198
+ ("output", _str_key(_flag("--output"))),
199
+ ("fields", _list_key(_fields_arg)),
200
+ *_COMMON_TAIL,
201
+ ),
202
+ "reduce": (
203
+ ("prompt", _str_key(_positional)),
204
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
205
+ ("prompt-file", _str_key(_prompt_file)),
206
+ ("model", _str_key(_flag("--model"))),
207
+ ("group-by", _str_key(_flag("--group-by"))),
208
+ ("window", _int_key(_flag("--window"))),
209
+ ("every", _int_key(_flag("--every"))),
210
+ ("verbose", _bool_key(_switch("--verbose"))),
211
+ ("schema-file", _str_key(_schema)),
212
+ ("schema-from", _str_key(_flag("--schema-from"))),
213
+ ("tally", _str_key(_flag("--tally"))),
214
+ ("explode", _str_key(_flag("--explode"))),
215
+ ("fields", _list_key(_fields_arg)),
216
+ *_COMMON_TAIL,
217
+ ),
218
+ "where": (("predicate", _str_key(_positional)),),
219
+ "extend": (
220
+ ("prompt", _str_key(_positional)),
221
+ ("prompt-file", _str_key(_prompt_file)),
222
+ ("model", _str_key(_flag("--model"))),
223
+ ("output", _str_key(_flag("--output"))),
224
+ ("fields", _list_key(_fields_arg)),
225
+ ("schema-file", _str_key(_schema)),
226
+ ("schema-from", _str_key(_flag("--schema-from"))),
227
+ ("tally", _str_key(_flag("--tally"))),
228
+ ("explode", _str_key(_flag("--explode"))),
229
+ *_COMMON_TAIL,
230
+ ),
231
+ "distinct": (
232
+ ("show-groups", _bool_key(_switch("--show-groups"))),
233
+ ("threshold", _num_key(_flag("--threshold"))),
234
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
235
+ ("embed-model", _str_key(_flag("--embed-model"))),
236
+ *_COMMON_TAIL,
237
+ ),
238
+ "outliers": (
239
+ ("count", _int_key(_positional)),
240
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
241
+ ("embed-model", _str_key(_flag("--embed-model"))),
242
+ *_COMMON_TAIL,
243
+ ),
244
+ "cluster": (
245
+ ("k", _int_key(_flag("--k"))),
246
+ ("top", _int_key(_flag("--top"))),
247
+ ("explode", _str_key(_flag("--explode"))),
248
+ ("model", _str_key(_flag("--model"))),
249
+ ("embed-model", _str_key(_flag("--embed-model"))),
250
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
251
+ *_COMMON_TAIL,
252
+ ),
253
+ "diff": (
254
+ ("right", _str_key(_right)),
255
+ ("top", _int_key(_flag("--top"))),
256
+ ("all", _bool_key(_switch("--all"))),
257
+ ("model", _str_key(_flag("--model"))),
258
+ ("embed-model", _str_key(_flag("--embed-model"))),
259
+ ("allow-captions", _bool_key(_switch("--allow-captions"))),
260
+ ("concurrency", _int_key(_flag("--concurrency"))),
261
+ ("max-calls", _int_key(_flag("--max-calls"))),
262
+ ),
263
+ "summarize": (("expression", _str_key(_positional)),),
264
+ "sample": (
265
+ ("count", _int_key(_positional)),
266
+ ("seed", _int_key(_flag("--seed"))),
267
+ ),
268
+ "getschema": (("all", _bool_key(_switch("--all"))),),
269
+ "sort": (
270
+ ("by", _str_key(_flag("--by"))),
271
+ ("desc", _bool_key(_switch("--desc"))),
272
+ ),
273
+ "chart": (
274
+ ("field", _str_key(_positional)),
275
+ ("facet", _str_key(_flag("--facet"))),
276
+ ("by-time", _str_key(_flag("--by-time"))),
277
+ ("top", _int_key(_flag("--top"))),
278
+ ("save", _str_key(_flag("--save"))),
279
+ ("title", _str_key(_flag("--title"))),
280
+ ),
281
+ }
282
+
283
+ _REQUIRES_PROMPT = ("map", "extend", "filter", "reduce", "join")
284
+ _REQUIRED_KEY: Mapping[str, str] = {
285
+ "where": "predicate",
286
+ "summarize": "expression",
287
+ "sample": "count",
288
+ "sort": "by",
289
+ "diff": "right",
290
+ }
291
+
292
+
293
+ def parse_sem(path: Path) -> list[str]:
294
+ """The argv the ``.sem`` file stands for — or a ``UsageFault`` naming what's wrong."""
295
+ try:
296
+ document = tomllib.loads(path.read_text(encoding="utf-8"))
297
+ except tomllib.TOMLDecodeError as exc:
298
+ raise UsageFault(_syntax_screen(path, exc)) from exc
299
+ verb = _checked_verb(path, document)
300
+ table = _VERB_KEYS[verb]
301
+ valid = tuple(key for key, _spec in table)
302
+ unknown = next((key for key in document if key != "verb" and key not in valid), None)
303
+ if unknown is not None:
304
+ raise UsageFault(
305
+ f"{path}: unknown key {unknown!r} — valid keys for {verb}: {', '.join(sorted(valid))}\n"
306
+ " A .sem script runs unattended — a typo silently ignored would be a disaster.\n"
307
+ f" Fix the key, then: smartpipe run {path}"
308
+ )
309
+ if verb in _REQUIRES_PROMPT and "prompt" not in document and "prompt-file" not in document:
310
+ raise UsageFault(f'{path}: {verb} needs a prompt\n Add one: prompt = "..."')
311
+ needed = _REQUIRED_KEY.get(verb)
312
+ if needed is not None and needed not in document:
313
+ raise UsageFault(f'{path}: {verb} needs {needed!r}\n Add one: {needed} = "..."')
314
+ argv = [verb]
315
+ for key, spec in table:
316
+ if key not in document:
317
+ continue
318
+ value = document[key]
319
+ if not spec.accepts(value):
320
+ raise UsageFault(_wrong_type_screen(path, key, spec.expected, value))
321
+ argv.extend(spec.render(value, path.parent))
322
+ return argv
323
+
324
+
325
+ def _checked_verb(path: Path, document: Mapping[str, object]) -> str:
326
+ verb = document.get("verb")
327
+ if verb is None:
328
+ raise UsageFault(
329
+ f"{path}: 'verb' is required ({_VERB_NAMES})\n"
330
+ " A .sem file is one saved pipe stage: a verb plus its keys.\n"
331
+ ' Start it with: verb = "map"'
332
+ )
333
+ if not isinstance(verb, str):
334
+ raise UsageFault(_wrong_type_screen(path, "verb", "a string", verb))
335
+ if verb in ("run", "config"):
336
+ spelled_out = _VERB_NAMES.replace(", reduce", ", or reduce")
337
+ raise UsageFault(
338
+ f"{path}: verb {verb!r} can't run from a script — use {spelled_out}\n"
339
+ " Scripts hold pipe stages; composition and setup stay at the shell."
340
+ )
341
+ if verb not in _VERB_KEYS:
342
+ raise UsageFault(
343
+ f"{path}: verb {verb!r} isn't one of {_VERB_NAMES}\n"
344
+ " A .sem file is one saved pipe stage: a verb plus its keys."
345
+ )
346
+ return verb
347
+
348
+
349
+ def _wrong_type_screen(path: Path, key: str, expected: str, value: object) -> str:
350
+ return (
351
+ f"{path}: key {key!r} should be {expected}\n"
352
+ f" got: {key} = {value!r}\n"
353
+ f" Fix the line, then: smartpipe run {path}"
354
+ )
355
+
356
+
357
+ def _syntax_screen(path: Path, exc: tomllib.TOMLDecodeError) -> str:
358
+ detail = str(exc)
359
+ located = re.search(r"at line (\d+)", detail)
360
+ location = f", line {located.group(1)}" if located else ""
361
+ detail = re.sub(r"\s*\(at line [^)]*\)$", "", detail)
362
+ return (
363
+ f"{path} has a syntax error\n"
364
+ f" {path}{location}: {detail}\n"
365
+ f" Fix the line, then: smartpipe run {path}"
366
+ )
367
+
368
+
369
+ # --- multi-stage pipelines (D38/14) ---------------------------------------------------
370
+
371
+
372
+ @dataclass(frozen=True, slots=True)
373
+ class Stage:
374
+ name: str
375
+ argv: tuple[str, ...]
376
+ input_name: str | None # None: the previous stage (or real stdin for the first)
377
+
378
+
379
+ def parse_pipeline(path: Path) -> tuple[Stage, ...] | None:
380
+ """The stages a multi-stage ``.sem`` stands for; None for single-stage
381
+ files (the original format is the degenerate case and stays untouched)."""
382
+ try:
383
+ document = tomllib.loads(path.read_text(encoding="utf-8"))
384
+ except tomllib.TOMLDecodeError as exc:
385
+ raise UsageFault(_syntax_screen(path, exc)) from exc
386
+ from smartpipe.core.jsontools import as_record
387
+
388
+ raw_stages = document.get("stage")
389
+ if raw_stages is None:
390
+ return None
391
+ stages_table = as_record(raw_stages)
392
+ if stages_table is None:
393
+ raise UsageFault(f"{path}: [stage.NAME] tables expected under 'stage'")
394
+ others = [key for key in document if key != "stage"]
395
+ if others:
396
+ raise UsageFault(
397
+ f"{path}: a pipeline file holds only [stage.NAME] tables — "
398
+ f"found top-level {others[0]!r}\n"
399
+ " Single-stage files use top-level keys; pick one shape."
400
+ )
401
+ stages: list[Stage] = []
402
+ seen: list[str] = []
403
+ for name, raw_body in stages_table.items():
404
+ body = as_record(raw_body)
405
+ if body is None:
406
+ raise UsageFault(f"{path}: [stage.{name}] must be a table of keys")
407
+ upstream_value = body.get("input")
408
+ upstream: str | None = None
409
+ if upstream_value is not None:
410
+ if not isinstance(upstream_value, str):
411
+ raise UsageFault(
412
+ _wrong_type_screen(path, f"stage.{name}.input", "a string", upstream_value)
413
+ )
414
+ upstream = upstream_value
415
+ if upstream not in seen:
416
+ raise UsageFault(
417
+ f"{path}: stage {name!r} reads input = {upstream!r}, "
418
+ "which isn't an EARLIER stage\n"
419
+ " Stages run in file order; input names one above."
420
+ )
421
+ stage_doc = {key: value for key, value in body.items() if key != "input"}
422
+ argv = _stage_argv(path, name, stage_doc)
423
+ stages.append(Stage(name=name, argv=tuple(argv), input_name=upstream))
424
+ seen.append(name)
425
+ if not stages:
426
+ raise UsageFault(f"{path}: a pipeline needs at least one [stage.NAME]")
427
+ return tuple(stages)
428
+
429
+
430
+ def _stage_argv(path: Path, name: str, document: dict[str, object]) -> list[str]:
431
+ verb = _checked_verb(path, document)
432
+ table = _VERB_KEYS[verb]
433
+ valid = tuple(key for key, _spec in table)
434
+ unknown = next((key for key in document if key != "verb" and key not in valid), None)
435
+ if unknown is not None:
436
+ raise UsageFault(
437
+ f"{path}: [stage.{name}] unknown key {unknown!r} — "
438
+ f"valid keys for {verb}: {', '.join(sorted(valid))}"
439
+ )
440
+ if verb in _REQUIRES_PROMPT and "prompt" not in document and "prompt-file" not in document:
441
+ raise UsageFault(f"{path}: [stage.{name}] {verb} needs a prompt")
442
+ needed = _REQUIRED_KEY.get(verb)
443
+ if needed is not None and needed not in document:
444
+ raise UsageFault(f"{path}: [stage.{name}] {verb} needs {needed!r}")
445
+ argv = [verb]
446
+ for key, spec in table:
447
+ if key not in document:
448
+ continue
449
+ value = document[key]
450
+ if not spec.accepts(value):
451
+ raise UsageFault(_wrong_type_screen(path, f"stage.{name}.{key}", spec.expected, value))
452
+ argv.extend(spec.render(value, path.parent))
453
+ return argv