smartpipe-cli 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
smartpipe/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)
@@ -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