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/cli/screens.py
ADDED
|
@@ -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
|