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/root.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""The smartpipe entry point: root command group and exit-code mapping.
|
|
2
|
+
|
|
3
|
+
click's built-in usage-error exit code (2) collides with the spec's "no model
|
|
4
|
+
configured" (plan/decisions.md D12), so ``main`` maps click exceptions onto the
|
|
5
|
+
``ExitCode`` contract itself instead of using click's standalone mode.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import sys
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from smartpipe import __version__
|
|
20
|
+
from smartpipe.cli.auth_cmd import auth_command
|
|
21
|
+
from smartpipe.cli.cache_cmd import cache_command
|
|
22
|
+
from smartpipe.cli.chart_cmd import chart_command
|
|
23
|
+
from smartpipe.cli.cite_cmd import cite_command
|
|
24
|
+
from smartpipe.cli.cluster_cmd import cluster_command
|
|
25
|
+
from smartpipe.cli.config_cmd import config_command
|
|
26
|
+
from smartpipe.cli.diff_cmd import diff_command
|
|
27
|
+
from smartpipe.cli.distinct_cmd import distinct_command
|
|
28
|
+
from smartpipe.cli.doctor_cmd import doctor_command
|
|
29
|
+
from smartpipe.cli.echo_cmd import echo_command
|
|
30
|
+
from smartpipe.cli.embed_cmd import embed_command
|
|
31
|
+
from smartpipe.cli.extend_cmd import extend_command
|
|
32
|
+
from smartpipe.cli.filter_cmd import filter_command
|
|
33
|
+
from smartpipe.cli.getschema_cmd import getschema_command
|
|
34
|
+
from smartpipe.cli.join_cmd import join_command
|
|
35
|
+
from smartpipe.cli.map_cmd import map_command
|
|
36
|
+
from smartpipe.cli.outliers_cmd import outliers_command
|
|
37
|
+
from smartpipe.cli.reduce_cmd import reduce_command
|
|
38
|
+
from smartpipe.cli.run_cmd import run_command
|
|
39
|
+
from smartpipe.cli.sample_cmd import sample_command
|
|
40
|
+
from smartpipe.cli.schema_cmd import schema_command
|
|
41
|
+
from smartpipe.cli.screens import WELCOME
|
|
42
|
+
from smartpipe.cli.sort_cmd import sort_command
|
|
43
|
+
from smartpipe.cli.split_cmd import split_command
|
|
44
|
+
from smartpipe.cli.summarize_cmd import summarize_command
|
|
45
|
+
from smartpipe.cli.top_k_cmd import top_k_command
|
|
46
|
+
from smartpipe.cli.usage_cmd import usage_command
|
|
47
|
+
from smartpipe.cli.where_cmd import where_command
|
|
48
|
+
from smartpipe.core.errors import ExitCode, SempipeError, UsageFault
|
|
49
|
+
|
|
50
|
+
if True: # typing-only import kept runtime-cheap
|
|
51
|
+
from collections.abc import Iterable
|
|
52
|
+
from smartpipe.io import diagnostics
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
__all__ = ["cli", "main"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_ALIASES = {"top-k": "top_k", "topk": "top_k"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _StyledHelpFormatter(click.HelpFormatter):
|
|
64
|
+
"""Color in --help (D42): headings cyan, commands/options green. ANSI is
|
|
65
|
+
stripped by click when piped; NO_COLOR turns it off entirely."""
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _on() -> bool:
|
|
69
|
+
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
70
|
+
|
|
71
|
+
def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None:
|
|
72
|
+
if self._on():
|
|
73
|
+
prog = click.style(prog, bold=True)
|
|
74
|
+
prefix = click.style(prefix if prefix is not None else "Usage: ", fg="cyan", bold=True)
|
|
75
|
+
super().write_usage(prog, args, prefix)
|
|
76
|
+
|
|
77
|
+
def write_heading(self, heading: str) -> None:
|
|
78
|
+
if self._on():
|
|
79
|
+
heading = click.style(heading, fg="cyan", bold=True)
|
|
80
|
+
super().write_heading(heading)
|
|
81
|
+
|
|
82
|
+
def write_dl(
|
|
83
|
+
self,
|
|
84
|
+
rows: Iterable[tuple[str, str]],
|
|
85
|
+
col_max: int = 30,
|
|
86
|
+
col_spacing: int = 2,
|
|
87
|
+
) -> None:
|
|
88
|
+
if self._on():
|
|
89
|
+
rows = [(click.style(term, fg="green"), body) for term, body in rows]
|
|
90
|
+
super().write_dl(rows, col_max, col_spacing)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _StyledContext(click.Context):
|
|
94
|
+
formatter_class = _StyledHelpFormatter
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _RootGroup(click.Group):
|
|
98
|
+
"""Print the welcome screen when invoked bare (plan/ux.md, spec §14)."""
|
|
99
|
+
|
|
100
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
101
|
+
if not args:
|
|
102
|
+
plain = bool(os.environ.get("NO_COLOR"))
|
|
103
|
+
click.echo(WELCOME, nl=False, color=False if plain else None)
|
|
104
|
+
ctx.exit(int(ExitCode.OK))
|
|
105
|
+
return super().parse_args(ctx, args)
|
|
106
|
+
|
|
107
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
108
|
+
# Muscle-memory forgiveness for top_k's spelling; not shown in help.
|
|
109
|
+
built_in = super().get_command(ctx, _ALIASES.get(cmd_name, cmd_name))
|
|
110
|
+
if built_in is not None:
|
|
111
|
+
return built_in # built-ins always win — custom verbs never shadow
|
|
112
|
+
return _user_sem_command(cmd_name) or _entry_point_command(cmd_name)
|
|
113
|
+
|
|
114
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
115
|
+
names = set(super().list_commands(ctx))
|
|
116
|
+
names.update(_user_sem_names())
|
|
117
|
+
names.update(_entry_point_names())
|
|
118
|
+
return sorted(names)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _verbs_dir() -> Path:
|
|
122
|
+
from smartpipe.config.paths import config_path
|
|
123
|
+
|
|
124
|
+
return config_path(os.environ).parent / "verbs"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _user_sem_names() -> list[str]:
|
|
128
|
+
try:
|
|
129
|
+
return [path.stem for path in _verbs_dir().glob("*.sem")]
|
|
130
|
+
except OSError:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _user_sem_command(name: str) -> click.Command | None:
|
|
135
|
+
"""Leg 1 of the custom-verb contract (D39/06): ~/.config/smartpipe/verbs/
|
|
136
|
+
NAME.sem becomes `smartpipe NAME` — full key validation, zero machinery."""
|
|
137
|
+
script = _verbs_dir() / f"{name}.sem"
|
|
138
|
+
if not script.is_file():
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
@click.command(name=name, help=f"Custom verb from {script} (.sem file).")
|
|
142
|
+
def sem_verb() -> None:
|
|
143
|
+
from smartpipe.cli.run_cmd import execute_script
|
|
144
|
+
|
|
145
|
+
execute_script(script)
|
|
146
|
+
|
|
147
|
+
return sem_verb
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _entry_point_names() -> list[str]:
|
|
151
|
+
from importlib.metadata import entry_points
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
return [point.name for point in entry_points(group="smartpipe.verbs")]
|
|
155
|
+
except Exception:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _entry_point_command(name: str) -> click.Command | None:
|
|
160
|
+
"""Leg 2 (D39/06): a package's entry point in group `smartpipe.verbs` naming
|
|
161
|
+
a click.Command. Broken plugins warn once and are skipped."""
|
|
162
|
+
from importlib.metadata import entry_points
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
matches = [point for point in entry_points(group="smartpipe.verbs") if point.name == name]
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
for point in matches:
|
|
169
|
+
try:
|
|
170
|
+
loaded: object = point.load()
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
diagnostics.warn(f"custom verb {name!r} failed to load ({exc}) — skipped")
|
|
173
|
+
return None
|
|
174
|
+
if isinstance(loaded, click.Command):
|
|
175
|
+
return loaded
|
|
176
|
+
diagnostics.warn(f"custom verb {name!r} is not a click.Command — skipped")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@click.group(cls=_RootGroup)
|
|
181
|
+
@click.version_option(__version__, prog_name="smartpipe", message="%(prog)s %(version)s")
|
|
182
|
+
def cli() -> None:
|
|
183
|
+
"""smartpipe — semantic pipes for your terminal: documents, images, audio, video, text."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
cli.add_command(map_command)
|
|
187
|
+
cli.add_command(extend_command)
|
|
188
|
+
cli.add_command(filter_command)
|
|
189
|
+
cli.add_command(embed_command)
|
|
190
|
+
cli.add_command(top_k_command)
|
|
191
|
+
cli.add_command(reduce_command)
|
|
192
|
+
cli.add_command(join_command)
|
|
193
|
+
cli.add_command(cluster_command)
|
|
194
|
+
cli.add_command(diff_command)
|
|
195
|
+
cli.add_command(distinct_command)
|
|
196
|
+
cli.add_command(outliers_command)
|
|
197
|
+
cli.add_command(run_command)
|
|
198
|
+
cli.add_command(config_command)
|
|
199
|
+
cli.add_command(doctor_command)
|
|
200
|
+
cli.add_command(schema_command)
|
|
201
|
+
cli.add_command(split_command)
|
|
202
|
+
cli.add_command(chart_command)
|
|
203
|
+
cli.add_command(where_command)
|
|
204
|
+
cli.add_command(summarize_command)
|
|
205
|
+
cli.add_command(sample_command)
|
|
206
|
+
cli.add_command(getschema_command)
|
|
207
|
+
cli.add_command(sort_command)
|
|
208
|
+
cli.add_command(auth_command)
|
|
209
|
+
cli.add_command(cache_command)
|
|
210
|
+
cli.add_command(usage_command)
|
|
211
|
+
cli.add_command(cite_command)
|
|
212
|
+
cli.add_command(echo_command)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _stylize(command: click.Command) -> None:
|
|
216
|
+
command.context_class = _StyledContext
|
|
217
|
+
if isinstance(command, click.Group):
|
|
218
|
+
for sub in command.commands.values():
|
|
219
|
+
_stylize(sub)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
_stylize(cli)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _stdout_is_real() -> bool:
|
|
226
|
+
"""True when stdout is an OS-level stream (tty or pipe), not a capture."""
|
|
227
|
+
try:
|
|
228
|
+
return sys.stdout.fileno() >= 0
|
|
229
|
+
except (OSError, ValueError): # capture objects raise UnsupportedOperation (a ValueError)
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main() -> None:
|
|
234
|
+
# standalone_mode=False so *we* own exit codes. In this mode click does not
|
|
235
|
+
# sys.exit(): a ctx.exit(n) / --version / --help comes back as a plain int
|
|
236
|
+
# return value (verified against click 8.4), and UsageError is raised.
|
|
237
|
+
# --debug becomes a real global flag with the first verb (stage 3); until then
|
|
238
|
+
# the env var keeps tracebacks reachable for development.
|
|
239
|
+
if hasattr(signal, "SIGPIPE") and _stdout_is_real():
|
|
240
|
+
# POSIX: when downstream closes, die exactly like grep. Guarded to
|
|
241
|
+
# REAL stdout streams — under a test harness's capture there is no
|
|
242
|
+
# pipe to honor, and SIG_DFL process-wide lets any stray SIGPIPE
|
|
243
|
+
# (worker threads, GC'd pipes) kill the harness itself (seen only
|
|
244
|
+
# on the GitHub runner: exit 141 at a moving test).
|
|
245
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
246
|
+
debug = "SMARTPIPE_DEBUG" in os.environ
|
|
247
|
+
try:
|
|
248
|
+
result = cli.main(
|
|
249
|
+
standalone_mode=False,
|
|
250
|
+
prog_name="smartpipe",
|
|
251
|
+
# click glob-expands wildcard args itself on windows - that would
|
|
252
|
+
# pre-explode a quoted --in '*.txt' into positionals and break the
|
|
253
|
+
# "we expand our own globs" contract (D43-era; caught by CI round 10)
|
|
254
|
+
windows_expand_args=False,
|
|
255
|
+
)
|
|
256
|
+
except BrokenPipeError: # Windows / buffered-flush edge; POSIX rarely reaches here
|
|
257
|
+
with contextlib.suppress(OSError, ValueError): # silence the shutdown flush
|
|
258
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
259
|
+
raise SystemExit(int(ExitCode.PIPE_CLOSED)) from None
|
|
260
|
+
except click.UsageError as exc:
|
|
261
|
+
prefix = (
|
|
262
|
+
click.style("error:", fg="red")
|
|
263
|
+
if sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
|
264
|
+
else "error:"
|
|
265
|
+
)
|
|
266
|
+
click.echo(f"{prefix} {exc.format_message()}", err=True)
|
|
267
|
+
command_path = exc.ctx.command_path if exc.ctx is not None else "smartpipe"
|
|
268
|
+
click.echo(f" try: {command_path} --help", err=True)
|
|
269
|
+
raise SystemExit(int(ExitCode.USAGE)) from exc
|
|
270
|
+
except SempipeError as exc:
|
|
271
|
+
diagnostics.die(exc, debug=debug)
|
|
272
|
+
except KeyboardInterrupt:
|
|
273
|
+
raise SystemExit(int(ExitCode.INTERRUPTED)) from None
|
|
274
|
+
except asyncio.CancelledError: # the drain watchdog cancelled the run (ux.md §12)
|
|
275
|
+
raise SystemExit(int(ExitCode.INTERRUPTED)) from None
|
|
276
|
+
except click.ClickException as exc: # click-internal faults (e.g. click.FileError)
|
|
277
|
+
diagnostics.die(UsageFault(exc.format_message()), debug=debug)
|
|
278
|
+
except Exception as exc: # the last-resort BUG screen (exit 70) — never a raw traceback
|
|
279
|
+
diagnostics.internal_error(exc, debug=debug)
|
|
280
|
+
if isinstance(result, int) and result != 0:
|
|
281
|
+
raise SystemExit(result)
|
smartpipe/cli/run_cmd.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""``smartpipe run`` — execute a ``.sem`` file: one stage, or a pipeline (D17/D38-14).
|
|
2
|
+
|
|
3
|
+
Single-stage files trampoline into their verb unchanged. Pipeline files
|
|
4
|
+
([stage.NAME] tables) run stages sequentially in-process: each stage's stdout
|
|
5
|
+
feeds the next (or a named earlier stage), the last stage writes the real
|
|
6
|
+
stdout, and every stage's stderr is prefixed with its name. ``--dry-run``
|
|
7
|
+
prints the resolved graph with each stage's cost posture and runs nothing —
|
|
8
|
+
D18 at pipeline scale.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from smartpipe.cli.sem_file import parse_pipeline, parse_sem
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from smartpipe.cli.sem_file import Stage
|
|
24
|
+
|
|
25
|
+
__all__ = ["execute_script", "run_command"]
|
|
26
|
+
|
|
27
|
+
_FREE_VERBS = frozenset({"where", "split", "chart", "sort", "sample", "summarize", "getschema"})
|
|
28
|
+
_EMBED_VERBS = frozenset({"embed", "distinct", "outliers"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _posture(verb: str) -> str:
|
|
32
|
+
if verb in _FREE_VERBS:
|
|
33
|
+
return "free"
|
|
34
|
+
if verb in _EMBED_VERBS:
|
|
35
|
+
return "embeddings"
|
|
36
|
+
return "model calls"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _PrefixedStderr(io.TextIOBase):
|
|
40
|
+
"""Line-prefixes a stage's stderr so interleaved receipts stay readable."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, prefix: str, target: object) -> None:
|
|
43
|
+
self.prefix = prefix
|
|
44
|
+
self.target = target
|
|
45
|
+
self._at_line_start = True
|
|
46
|
+
|
|
47
|
+
def write(self, text: str) -> int:
|
|
48
|
+
for line in text.splitlines(keepends=True):
|
|
49
|
+
if self._at_line_start and line.strip():
|
|
50
|
+
self.target.write(self.prefix) # type: ignore[attr-defined]
|
|
51
|
+
self.target.write(line) # type: ignore[attr-defined]
|
|
52
|
+
self._at_line_start = line.endswith("\n")
|
|
53
|
+
return len(text)
|
|
54
|
+
|
|
55
|
+
def flush(self) -> None:
|
|
56
|
+
self.target.flush() # type: ignore[attr-defined]
|
|
57
|
+
|
|
58
|
+
def isatty(self) -> bool:
|
|
59
|
+
return False # stage receipts never animate spinners
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.command(name="run", context_settings={"ignore_unknown_options": True})
|
|
63
|
+
@click.argument("script", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
64
|
+
@click.option("--dry-run", "dry_run", is_flag=True, help="Print the pipeline graph; run nothing.")
|
|
65
|
+
@click.argument("extra", nargs=-1, type=click.UNPROCESSED)
|
|
66
|
+
def run_command(script: Path, dry_run: bool, extra: tuple[str, ...]) -> None:
|
|
67
|
+
"""Execute a .sem file — one stage, or a whole pipeline.
|
|
68
|
+
|
|
69
|
+
\b
|
|
70
|
+
Examples:
|
|
71
|
+
smartpipe run extract.sem < cards.txt
|
|
72
|
+
smartpipe run triage.sem --dry-run # the graph + cost posture, zero calls
|
|
73
|
+
cat tickets.log | smartpipe run triage.sem > report.txt
|
|
74
|
+
|
|
75
|
+
A single-stage file is TOML pinning one verb invocation. A pipeline file
|
|
76
|
+
holds [stage.NAME] tables run in order — each stage reads the previous
|
|
77
|
+
stage's output (or 'input = "name"' picks an earlier one); the first
|
|
78
|
+
reads stdin, the last writes stdout. Extra flags apply to single-stage
|
|
79
|
+
files only.
|
|
80
|
+
"""
|
|
81
|
+
execute_script(script, extra=extra, dry_run=dry_run)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def execute_script(script: Path, *, extra: tuple[str, ...] = (), dry_run: bool = False) -> None:
|
|
85
|
+
"""Run a .sem file (single stage or pipeline) — shared by ``smartpipe run``
|
|
86
|
+
and user-named custom verbs (D39/06)."""
|
|
87
|
+
stages = parse_pipeline(script)
|
|
88
|
+
if stages is None:
|
|
89
|
+
if dry_run:
|
|
90
|
+
argv = parse_sem(script)
|
|
91
|
+
click.echo(f"{script.name}: {' '.join(argv)} [{_posture(argv[0])}]")
|
|
92
|
+
return
|
|
93
|
+
_invoke(parse_sem(script) + list(extra))
|
|
94
|
+
return
|
|
95
|
+
if extra:
|
|
96
|
+
raise click.UsageError("extra flags apply to single-stage files only")
|
|
97
|
+
if dry_run:
|
|
98
|
+
for stage in stages:
|
|
99
|
+
click.echo(
|
|
100
|
+
f"stage {stage.name:<12} {' '.join(stage.argv)} [{_posture(stage.argv[0])}]"
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
_run_pipeline(stages)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _invoke(argv: list[str]) -> None:
|
|
107
|
+
context = click.get_current_context()
|
|
108
|
+
root = context.find_root().command
|
|
109
|
+
assert isinstance(root, click.Group) # run is only ever registered on the group
|
|
110
|
+
verb = root.get_command(context, argv[0])
|
|
111
|
+
assert verb is not None # the translator only emits real verbs
|
|
112
|
+
sub = verb.make_context(argv[0], list(argv[1:]), parent=context, ignore_unknown_options=False)
|
|
113
|
+
with sub:
|
|
114
|
+
verb.invoke(sub)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_pipeline(stages: tuple[Stage, ...]) -> None:
|
|
118
|
+
outputs: dict[str, str] = {}
|
|
119
|
+
previous: str | None = None
|
|
120
|
+
real_stdin, real_stdout, real_stderr = sys.stdin, sys.stdout, sys.stderr
|
|
121
|
+
for position, stage in enumerate(stages):
|
|
122
|
+
last = position == len(stages) - 1
|
|
123
|
+
source = stage.input_name if stage.input_name is not None else previous
|
|
124
|
+
stage_in = real_stdin if source is None else io.StringIO(outputs[source])
|
|
125
|
+
stage_out = real_stdout if last else io.StringIO()
|
|
126
|
+
sys.stdin = stage_in # type: ignore[assignment]
|
|
127
|
+
sys.stdout = stage_out # type: ignore[assignment]
|
|
128
|
+
sys.stderr = _PrefixedStderr(f"[{stage.name}] ", real_stderr) # type: ignore[assignment]
|
|
129
|
+
try:
|
|
130
|
+
_invoke(list(stage.argv))
|
|
131
|
+
finally:
|
|
132
|
+
sys.stdin, sys.stdout, sys.stderr = real_stdin, real_stdout, real_stderr
|
|
133
|
+
if not last:
|
|
134
|
+
assert isinstance(stage_out, io.StringIO)
|
|
135
|
+
outputs[stage.name] = stage_out.getvalue()
|
|
136
|
+
previous = stage.name
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``smartpipe sample`` — the same N random rows, every run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from smartpipe.core.errors import ExitCode
|
|
10
|
+
from smartpipe.verbs.sample import SampleRequest, run_sample
|
|
11
|
+
|
|
12
|
+
__all__ = ["sample_command"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(name="sample")
|
|
16
|
+
@click.argument("count", type=int)
|
|
17
|
+
@click.option(
|
|
18
|
+
"--seed", type=int, default=0, show_default=True, help="Vary the (still reproducible) sample."
|
|
19
|
+
)
|
|
20
|
+
def sample_command(count: int, seed: int) -> None:
|
|
21
|
+
"""Keep N random rows — seeded, reproducible. Free — never calls a model.
|
|
22
|
+
|
|
23
|
+
\b
|
|
24
|
+
Examples:
|
|
25
|
+
cat huge.jsonl | smartpipe sample 20 | smartpipe map "Extract {label}"
|
|
26
|
+
cat evals.jsonl | smartpipe sample 50 --seed 7 > eval-subset.jsonl
|
|
27
|
+
|
|
28
|
+
Deterministic BY DEFAULT: the same input gives the same sample with no
|
|
29
|
+
flags, so prompt comparisons compare prompts (and the sample is citable).
|
|
30
|
+
Unlike --max-calls (which takes the head of the stream), sample is
|
|
31
|
+
representative. Output keeps input order.
|
|
32
|
+
"""
|
|
33
|
+
code = run_sample(SampleRequest(count=count, seed=seed), stdin=sys.stdin, stdout=sys.stdout)
|
|
34
|
+
if code is not ExitCode.OK: # pragma: no cover — sample always OKs
|
|
35
|
+
raise SystemExit(int(code))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``smartpipe schema`` — rung 4 of the ladder (D22): English → a validated schema file.
|
|
2
|
+
|
|
3
|
+
Exactly one drafting call plus at most one repair. The draft is validated against
|
|
4
|
+
the JSON-Schema meta-schema before stdout sees a byte: an invalid draft is exit 3
|
|
5
|
+
with the attempt on stderr and **stdout empty** — a broken schema silently piped
|
|
6
|
+
into the next run is the disaster case this command exists to prevent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from smartpipe.cli.completions import complete_chat_models
|
|
18
|
+
from smartpipe.core.errors import ExitCode, ItemError
|
|
19
|
+
from smartpipe.engine.prompts import build_repair_request, build_schema_request
|
|
20
|
+
from smartpipe.engine.schema import parse_schema_draft
|
|
21
|
+
from smartpipe.io import diagnostics
|
|
22
|
+
|
|
23
|
+
__all__ = ["schema_command"]
|
|
24
|
+
|
|
25
|
+
_FAILED_SCREEN = (
|
|
26
|
+
"error: the model couldn't produce a valid JSON Schema (after one repair)\n"
|
|
27
|
+
" Its last attempt is above on stderr. Try rephrasing, or write the file by hand:\n"
|
|
28
|
+
" docs/concepts/structured-output.md"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command(name="schema")
|
|
33
|
+
@click.argument("description")
|
|
34
|
+
@click.option(
|
|
35
|
+
"--model",
|
|
36
|
+
"model_flag",
|
|
37
|
+
shell_complete=complete_chat_models,
|
|
38
|
+
help="Model to draft with (one call + at most one repair).",
|
|
39
|
+
)
|
|
40
|
+
def schema_command(description: str, model_flag: str | None) -> None:
|
|
41
|
+
"""Draft a JSON Schema from an English description, validated before output.
|
|
42
|
+
|
|
43
|
+
\b
|
|
44
|
+
Examples:
|
|
45
|
+
smartpipe schema "invoice with vendor string, total number, status paid/unpaid" > invoice.json
|
|
46
|
+
cat receipts.txt | smartpipe map "Extract the fields" --schema invoice.json
|
|
47
|
+
|
|
48
|
+
The draft is checked against the JSON-Schema meta-schema; a failed draft
|
|
49
|
+
exits 3 with NOTHING on stdout, so a broken schema can never slip into a pipe.
|
|
50
|
+
"""
|
|
51
|
+
code = asyncio.run(_run(description, model_flag))
|
|
52
|
+
if code is not ExitCode.OK:
|
|
53
|
+
raise SystemExit(int(code))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _run(description: str, model_flag: str | None) -> ExitCode:
|
|
57
|
+
from smartpipe.container import build_container
|
|
58
|
+
|
|
59
|
+
async with build_container(os.environ) as container:
|
|
60
|
+
model = await container.chat_model(model_flag)
|
|
61
|
+
request = build_schema_request(description)
|
|
62
|
+
reply = await model.complete(request)
|
|
63
|
+
try:
|
|
64
|
+
draft = parse_schema_draft(reply)
|
|
65
|
+
except ItemError as first_error:
|
|
66
|
+
repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
|
|
67
|
+
reply = await model.complete(repair)
|
|
68
|
+
try:
|
|
69
|
+
draft = parse_schema_draft(reply)
|
|
70
|
+
except ItemError:
|
|
71
|
+
diagnostics.warn(f"the model's last draft attempt:\n{reply}")
|
|
72
|
+
diagnostics.report_error(_FAILED_SCREEN)
|
|
73
|
+
return ExitCode.ALL_FAILED
|
|
74
|
+
click.echo(json.dumps(draft, indent=2, ensure_ascii=False))
|
|
75
|
+
return ExitCode.OK
|