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,87 @@
1
+ """``smartpipe filter`` — keep items matching a semantic condition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from smartpipe.cli.input_options import input_options, input_spec, resolve_prompt
13
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
14
+ from smartpipe.core.errors import ExitCode
15
+ from smartpipe.verbs.filter import FilterRequest, run_filter
16
+
17
+ __all__ = ["filter_command"]
18
+
19
+
20
+ @click.command(name="filter")
21
+ @click.argument("condition", required=False)
22
+ @click.option(
23
+ "--prompt-file",
24
+ "prompt_file",
25
+ type=click.Path(path_type=Path),
26
+ help="Read the condition from a file (the @file shorthand does the same).",
27
+ )
28
+ @click.option("--not", "invert", is_flag=True, help="Keep items that do NOT match (like grep -v).")
29
+ @click.option("--model", "model_flag", help="Model for this run.")
30
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
31
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
32
+ @click.option(
33
+ "--allow-captions",
34
+ "allow_captions",
35
+ is_flag=True,
36
+ help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
37
+ )
38
+ @input_options
39
+ def filter_command(
40
+ condition: str | None,
41
+ prompt_file: Path | None,
42
+ invert: bool,
43
+ model_flag: str | None,
44
+ concurrency_flag: int | None,
45
+ max_calls: int | None,
46
+ allow_captions: bool,
47
+ in_patterns: tuple[str, ...],
48
+ from_files: bool,
49
+ ) -> None:
50
+ """Keep items matching a plain-English condition. Semantic grep.
51
+
52
+ \b
53
+ Examples:
54
+ cat reviews.txt | smartpipe filter "the reviewer is sarcastic"
55
+ cat tickets.jsonl | smartpipe filter "{priority} is wrong given {description}"
56
+ smartpipe filter "mentions a security issue" --in 'logs/*.txt'
57
+
58
+ Output is the matching input items, unchanged and in order (in file mode, the
59
+ matching filenames). Zero matches is a successful (exit 0) empty result.
60
+ Deterministic condition (field == value, text has "word")? where is free.
61
+ """
62
+ request = FilterRequest(
63
+ allow_captions=allow_captions,
64
+ condition=resolve_prompt(condition, prompt_file),
65
+ invert=invert,
66
+ model_flag=model_flag,
67
+ concurrency_flag=concurrency_flag,
68
+ input=input_spec(in_patterns, from_files=from_files),
69
+ )
70
+ code = asyncio.run(_run(request, max_calls))
71
+ if code is not ExitCode.OK:
72
+ raise SystemExit(int(code))
73
+
74
+
75
+ async def _run(request: FilterRequest, max_calls: int | None) -> ExitCode:
76
+ from smartpipe.container import build_container
77
+
78
+ async with (
79
+ graceful_interrupts() as stop,
80
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
81
+ ):
82
+ if not request.allow_captions and container.config.allow_captions:
83
+ from dataclasses import replace as _replace
84
+
85
+ request = _replace(request, allow_captions=True) # profile consent (D35)
86
+ code = await run_filter(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
87
+ return settle_budget(container.budget, code)
@@ -0,0 +1,31 @@
1
+ """``smartpipe getschema`` — what fields does this stream even have?"""
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.getschema import GetSchemaRequest, run_getschema
11
+
12
+ __all__ = ["getschema_command"]
13
+
14
+
15
+ @click.command(name="getschema")
16
+ @click.option("--all", "scan_all", is_flag=True, help="Scan every row (default: first 10,000).")
17
+ def getschema_command(scan_all: bool) -> None:
18
+ """Report the stream's fields, types, and coverage. Free — never calls a model.
19
+
20
+ \b
21
+ Examples:
22
+ cat data.jsonl | smartpipe getschema
23
+ smartpipe getschema --all < big.jsonl
24
+
25
+ A table on a terminal, NDJSON when piped. Mixed types show as unions
26
+ (string|number) — that's the dirt worth seeing. Plain-text input gets a
27
+ one-line answer instead of an error. The footer suggests the next move.
28
+ """
29
+ code = run_getschema(GetSchemaRequest(scan_all=scan_all), stdin=sys.stdin, stdout=sys.stdout)
30
+ if code is not ExitCode.OK: # pragma: no cover — getschema always OKs
31
+ raise SystemExit(int(code))
@@ -0,0 +1,113 @@
1
+ """Shared verb options — file inputs (``--in``/``--from-files``) and ``--fields``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, TypeVar
6
+
7
+ import click
8
+
9
+ from smartpipe.core.errors import UsageFault
10
+ from smartpipe.io.inputs import InputSpec
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+
16
+ __all__ = ["fields_option", "input_options", "input_spec", "parse_fields", "resolve_prompt"]
17
+
18
+ _Command = TypeVar("_Command", bound="Callable[..., object]")
19
+
20
+ _FIELDS_HINT = (
21
+ " --fields is a comma-separated list of columns, each named once.\n"
22
+ " Example: --fields name,email"
23
+ )
24
+
25
+
26
+ def input_options(command: _Command) -> _Command:
27
+ """Attach ``--in`` (glob of files as items) and ``--from-files`` (stdin names files)."""
28
+ command = click.option(
29
+ "--in",
30
+ "in_patterns",
31
+ multiple=True,
32
+ metavar="GLOB",
33
+ help="Read each matching file as one item (repeatable). e.g. --in 'docs/*.pdf'",
34
+ )(command)
35
+ command = click.option(
36
+ "--from-files",
37
+ "from_files",
38
+ is_flag=True,
39
+ help="Treat each stdin line as a filename; read each file as one item.",
40
+ )(command)
41
+ return command
42
+
43
+
44
+ def input_spec(in_patterns: tuple[str, ...], *, from_files: bool) -> InputSpec:
45
+ return InputSpec(patterns=tuple(in_patterns), from_files=from_files)
46
+
47
+
48
+ def fields_option(command: _Command) -> _Command:
49
+ """Attach ``--fields a,b`` — select + order the columns of structured output."""
50
+ return click.option(
51
+ "--fields",
52
+ "fields",
53
+ metavar="A,B,...",
54
+ callback=_fields_callback,
55
+ help="Select and order output columns (structured output only). e.g. --fields name,email",
56
+ )(command)
57
+
58
+
59
+ def parse_fields(raw: str) -> tuple[str, ...]:
60
+ """``" a , b "`` → ``("a", "b")``; empty or duplicate names are usage errors."""
61
+ names = tuple(name.strip() for name in raw.split(","))
62
+ if any(not name for name in names):
63
+ raise UsageFault(f"--fields got an empty field name\n{_FIELDS_HINT}")
64
+ seen: set[str] = set()
65
+ for name in names:
66
+ if name in seen:
67
+ raise UsageFault(f"--fields names {name!r} more than once\n{_FIELDS_HINT}")
68
+ seen.add(name)
69
+ return names
70
+
71
+
72
+ def _fields_callback(
73
+ ctx: click.Context, param: click.Parameter, value: str | None
74
+ ) -> tuple[str, ...] | None:
75
+ del ctx, param # click's callback signature; the parse needs neither
76
+ return None if value is None else parse_fields(value)
77
+
78
+
79
+ def resolve_prompt(argument: str | None, file_flag: Path | None) -> str:
80
+ """D23: ``@file`` shorthand + ``--prompt-file`` — both spellings, one resolver.
81
+
82
+ Only a LEADING ``@`` is special; ``@@x`` escapes a literal ``@``. Missing and
83
+ empty files fail free at argv time (D18), before anything could cost money.
84
+ """
85
+ if argument is not None and file_flag is not None:
86
+ raise UsageFault("a prompt argument and --prompt-file both given — use one")
87
+ if file_flag is not None:
88
+ return _read_prompt_file(file_flag)
89
+ if argument is None:
90
+ raise UsageFault("no prompt given — write one, or point at a file: --prompt-file FILE")
91
+ if argument.startswith("@@"):
92
+ return argument[1:] # the escape: a literal leading @
93
+ if argument.startswith("@"):
94
+ from pathlib import Path as _Path
95
+
96
+ return _read_prompt_file(_Path(argument[1:]))
97
+ return argument
98
+
99
+
100
+ def _read_prompt_file(path: Path) -> str:
101
+ if not path.exists():
102
+ raise UsageFault(
103
+ f"prompt file not found: {path}\n"
104
+ " @file reads the prompt from a file; --prompt-file FILE is the explicit form.\n"
105
+ " A literal leading @ escapes as @@."
106
+ )
107
+ text = path.read_text(encoding="utf-8").removesuffix("\n")
108
+ if not text.strip():
109
+ raise UsageFault(
110
+ f"prompt file is empty: {path}\n"
111
+ " An empty prompt is never intended — write the prompt, or drop the @."
112
+ )
113
+ return text
@@ -0,0 +1,92 @@
1
+ """Graceful Ctrl-C for per-item verbs (ux.md §12).
2
+
3
+ First SIGINT sets the ``stop`` event — the runner stops intake and drains in-flight
4
+ work; a watchdog caps the drain. Second SIGINT hard-exits 130 immediately (the user
5
+ means it). Whole-set verbs don't enter this context and keep the immediate
6
+ KeyboardInterrupt → 130 path — no partial result exists for them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import os
14
+ import signal
15
+ import sys
16
+ from contextlib import asynccontextmanager
17
+ from typing import TYPE_CHECKING
18
+
19
+ from smartpipe.core.errors import ExitCode
20
+ from smartpipe.io import diagnostics
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import AsyncGenerator
24
+
25
+ from smartpipe.models.budget import CallBudget
26
+
27
+ __all__ = [
28
+ "drain_cap",
29
+ "graceful_interrupts",
30
+ "settle_budget",
31
+ ]
32
+
33
+ _DEFAULT_DRAIN_SECONDS = 10.0
34
+
35
+
36
+ def drain_cap() -> float:
37
+ # SMARTPIPE_DRAIN_SECONDS is a test seam (the drain-timeout e2e would otherwise
38
+ # take the full 10 s); not documented as public surface.
39
+ raw = os.environ.get("SMARTPIPE_DRAIN_SECONDS", "").strip()
40
+ try:
41
+ return float(raw) if raw else _DEFAULT_DRAIN_SECONDS
42
+ except ValueError:
43
+ return _DEFAULT_DRAIN_SECONDS
44
+
45
+
46
+ async def _watchdog(cap: float, task: asyncio.Task[object]) -> None:
47
+ await asyncio.sleep(cap)
48
+ diagnostics.drain_timed_out()
49
+ task.cancel()
50
+
51
+
52
+ @asynccontextmanager
53
+ async def graceful_interrupts() -> AsyncGenerator[asyncio.Event]:
54
+ stop = asyncio.Event()
55
+ loop = asyncio.get_running_loop()
56
+ task = asyncio.current_task()
57
+ assert task is not None # always inside asyncio.run here
58
+ sigints = 0
59
+ watchdog: asyncio.Task[None] | None = None
60
+
61
+ def on_sigint() -> None:
62
+ nonlocal sigints, watchdog
63
+ sigints += 1
64
+ if sigints == 1:
65
+ stop.set()
66
+ watchdog = loop.create_task(_watchdog(drain_cap(), task))
67
+ else: # the user means it — flush what we can and go
68
+ with contextlib.suppress(Exception):
69
+ sys.stdout.flush()
70
+ sys.stderr.flush()
71
+ os._exit(int(ExitCode.INTERRUPTED))
72
+
73
+ try:
74
+ loop.add_signal_handler(signal.SIGINT, on_sigint)
75
+ except (NotImplementedError, RuntimeError): # pragma: no cover — Windows loop
76
+ yield stop # never set: falls back to KeyboardInterrupt → 130 (documented)
77
+ return
78
+ try:
79
+ yield stop
80
+ finally:
81
+ loop.remove_signal_handler(signal.SIGINT)
82
+ if watchdog is not None:
83
+ watchdog.cancel()
84
+
85
+
86
+ def settle_budget(budget: CallBudget | None, code: ExitCode) -> ExitCode:
87
+ """D18: a run whose --max-calls budget fired never exits 0 — completeness
88
+ can't be trusted; the note names the cause after the drain summary."""
89
+ if budget is None or not budget.exhausted:
90
+ return code
91
+ diagnostics.note(f"stopped by --max-calls ({budget.calls} calls made)")
92
+ return ExitCode.PARTIAL if code is ExitCode.OK else code
@@ -0,0 +1,162 @@
1
+ """``smartpipe join`` — the CLI surface: flags in, verb out (D21)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from smartpipe.cli.completions import complete_chat_models, complete_embed_models
13
+ from smartpipe.cli.input_options import (
14
+ fields_option,
15
+ input_options,
16
+ input_spec,
17
+ resolve_prompt,
18
+ )
19
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
20
+ from smartpipe.core.errors import ExitCode
21
+ from smartpipe.io.writers import OutputFormat
22
+ from smartpipe.verbs.join import JoinRequest, run_join
23
+
24
+ __all__ = ["join_command"]
25
+
26
+
27
+ @click.command(name="join")
28
+ @click.argument("predicate", required=False)
29
+ @click.option(
30
+ "--prompt-file",
31
+ "prompt_file",
32
+ type=click.Path(path_type=Path),
33
+ help="Read the predicate from a file (the @file shorthand does the same).",
34
+ )
35
+ @click.option(
36
+ "--right",
37
+ "right",
38
+ required=True,
39
+ type=click.Path(dir_okay=False, path_type=Path, allow_dash=True),
40
+ help="The finite side to index (JSONL or plain lines). stdin is the left side.",
41
+ )
42
+ @click.option(
43
+ "--k",
44
+ "k",
45
+ type=int,
46
+ default=5,
47
+ show_default=True,
48
+ help="Candidates judged per left item (the recall knob — see the docs).",
49
+ )
50
+ @click.option(
51
+ "--threshold",
52
+ type=float,
53
+ help="Similarity floor (0-1) a candidate must clear before judging.",
54
+ )
55
+ @click.option(
56
+ "--kind",
57
+ type=click.Choice(["inner", "leftouter", "anti"]),
58
+ default="inner",
59
+ show_default=True,
60
+ help="inner: matched pairs · leftouter: all left rows (null right) · "
61
+ "anti: only UNMATCHED left rows, verbatim.",
62
+ )
63
+ @click.option(
64
+ "--unmatched",
65
+ "unmatched",
66
+ type=click.Path(path_type=Path),
67
+ help="Write left items with zero matches to FILE, verbatim (inner only).",
68
+ )
69
+ @click.option(
70
+ "--model",
71
+ "model_flag",
72
+ shell_complete=complete_chat_models,
73
+ help="Chat model for the judge calls.",
74
+ )
75
+ @click.option(
76
+ "--embed-model",
77
+ "embed_model_flag",
78
+ shell_complete=complete_embed_models,
79
+ help="Embedding model for both sides.",
80
+ )
81
+ @click.option(
82
+ "--output",
83
+ type=click.Choice([fmt.value for fmt in OutputFormat]),
84
+ default=OutputFormat.AUTO.value,
85
+ show_default=True,
86
+ help="Output format.",
87
+ )
88
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel left items.")
89
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
90
+ @fields_option
91
+ @click.option(
92
+ "--allow-captions",
93
+ "allow_captions",
94
+ is_flag=True,
95
+ help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
96
+ )
97
+ @input_options
98
+ def join_command(
99
+ predicate: str | None,
100
+ prompt_file: Path | None,
101
+ right: Path,
102
+ k: int,
103
+ threshold: float | None,
104
+ unmatched: Path | None,
105
+ kind: str,
106
+ model_flag: str | None,
107
+ embed_model_flag: str | None,
108
+ output: str,
109
+ concurrency_flag: int | None,
110
+ max_calls: int | None,
111
+ allow_captions: bool,
112
+ fields: tuple[str, ...] | None,
113
+ in_patterns: tuple[str, ...],
114
+ from_files: bool,
115
+ ) -> None:
116
+ """Match stdin against a second input, semantically. Emits matched pairs.
117
+
118
+ \b
119
+ Examples:
120
+ cat tickets.jsonl | smartpipe join "{left.text} concerns {right.name}" --right products.jsonl
121
+ tail -f events.log | smartpipe join "{left.text} involves {right.name}" --right people.jsonl
122
+ cat orders.jsonl | smartpipe join "the same purchase" --right invoices.jsonl --kind anti
123
+
124
+ Each brace names a side's field ({left.x} / {right.x}; .text is the whole
125
+ item). The right side is embedded once and indexed; each left item is
126
+ compared to its --k nearest candidates and only those pairs are judged by
127
+ the chat model — so cost is lines x k, never lines x right-size.
128
+ Output: {"left": {...}, "right": {...}, "_score": ...} per matched pair.
129
+ """
130
+ request = JoinRequest(
131
+ allow_captions=allow_captions,
132
+ predicate=resolve_prompt(predicate, prompt_file),
133
+ right=right,
134
+ k=k,
135
+ threshold=threshold,
136
+ unmatched=unmatched,
137
+ kind=kind,
138
+ model_flag=model_flag,
139
+ embed_model_flag=embed_model_flag,
140
+ concurrency_flag=concurrency_flag,
141
+ output=OutputFormat(output),
142
+ input=input_spec(in_patterns, from_files=from_files),
143
+ fields=fields,
144
+ )
145
+ code = asyncio.run(_run(request, max_calls))
146
+ if code is not ExitCode.OK:
147
+ raise SystemExit(int(code))
148
+
149
+
150
+ async def _run(request: JoinRequest, max_calls: int | None) -> ExitCode:
151
+ from smartpipe.container import build_container
152
+
153
+ async with (
154
+ graceful_interrupts() as stop,
155
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
156
+ ):
157
+ if not request.allow_captions and container.config.allow_captions:
158
+ from dataclasses import replace as _replace
159
+
160
+ request = _replace(request, allow_captions=True) # profile consent (D35)
161
+ code = await run_join(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
162
+ return settle_budget(container.budget, code)
@@ -0,0 +1,150 @@
1
+ """``smartpipe map`` — the CLI surface: flags in, verb out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from smartpipe.cli.completions import complete_chat_models
13
+ from smartpipe.cli.input_options import (
14
+ fields_option,
15
+ input_options,
16
+ input_spec,
17
+ resolve_prompt,
18
+ )
19
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
20
+ from smartpipe.core.errors import ExitCode
21
+ from smartpipe.io.writers import OutputFormat
22
+ from smartpipe.verbs.map import MapRequest, run_map
23
+
24
+ __all__ = ["map_command"]
25
+
26
+
27
+ @click.command(name="map")
28
+ @click.argument("prompt", required=False)
29
+ @click.option(
30
+ "--prompt-file",
31
+ "prompt_file",
32
+ type=click.Path(path_type=Path),
33
+ help="Prompt from a file (@file works too).",
34
+ )
35
+ @click.option(
36
+ "--explode",
37
+ "explode_field",
38
+ metavar="FIELD",
39
+ help="One row per element of a list-valued FIELD.",
40
+ )
41
+ @click.option(
42
+ "--tally",
43
+ "tally_field",
44
+ metavar="FIELD",
45
+ help="Count FIELD's values (live tally on stderr).",
46
+ )
47
+ @click.option(
48
+ "--schema-from",
49
+ "schema_dsl",
50
+ metavar="DSL",
51
+ help='Schema from a mini-DSL: "vendor string; total number >= 0".',
52
+ )
53
+ @click.option(
54
+ "--schema",
55
+ "schema_path",
56
+ type=click.Path(path_type=Path),
57
+ help="Enforce a JSON Schema on the output.",
58
+ )
59
+ @click.option(
60
+ "--model",
61
+ "model_flag",
62
+ shell_complete=complete_chat_models,
63
+ help="Model for this run (e.g. ollama/qwen3:8b, claude-opus-4-8).",
64
+ )
65
+ @click.option(
66
+ "--output",
67
+ type=click.Choice([fmt.value for fmt in OutputFormat]),
68
+ default=OutputFormat.AUTO.value,
69
+ show_default=True,
70
+ help="Output format.",
71
+ )
72
+ @click.option(
73
+ "--frame-every",
74
+ "frame_every",
75
+ type=float,
76
+ metavar="SECONDS",
77
+ help="Video density guarantee: one frame per period (lifts the 24-frame cap).",
78
+ )
79
+ @click.option(
80
+ "--max-frames",
81
+ "max_frames",
82
+ type=int,
83
+ help="Video frame budget (default 24; the smaller of the two flags wins).",
84
+ )
85
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
86
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
87
+ @fields_option
88
+ @input_options
89
+ def map_command(
90
+ prompt: str | None,
91
+ frame_every: float | None,
92
+ max_frames: int | None,
93
+ prompt_file: Path | None,
94
+ schema_path: Path | None,
95
+ schema_dsl: str | None,
96
+ tally_field: str | None,
97
+ explode_field: str | None,
98
+ model_flag: str | None,
99
+ output: str,
100
+ concurrency_flag: int | None,
101
+ max_calls: int | None,
102
+ fields: tuple[str, ...] | None,
103
+ in_patterns: tuple[str, ...],
104
+ from_files: bool,
105
+ ) -> None:
106
+ """Transform each input item with a prompt. One item in, one result out.
107
+
108
+ \b
109
+ Examples:
110
+ echo "hello" | smartpipe map "translate to Spanish"
111
+ cat reviews.jsonl | smartpipe map "Extract {product, sentiment}"
112
+ smartpipe map "Summarize this document" --in 'reports/*.pdf'
113
+ smartpipe map "What does the caller want?" --in 'calls/*.mp3'
114
+
115
+ You usually need NO flags: braces in the prompt name the JSON fields you
116
+ want back; plain prompts return plain text; and media is first-class —
117
+ images, audio, video, and the figures inside PDFs go to the model natively
118
+ when it supports them, converted (and disclosed) when it doesn't.
119
+
120
+ Everything else is opt-in refinement: schemas when braces aren't enough,
121
+ --tally/--explode/--fields to shape output, --max-calls to cap spend.
122
+ """
123
+ request = MapRequest(
124
+ prompt=resolve_prompt(prompt, prompt_file),
125
+ schema_path=schema_path,
126
+ schema_dsl=schema_dsl,
127
+ tally_field=tally_field,
128
+ explode_field=explode_field,
129
+ frame_every=frame_every,
130
+ max_frames=max_frames,
131
+ model_flag=model_flag,
132
+ output=OutputFormat(output),
133
+ concurrency_flag=concurrency_flag,
134
+ input=input_spec(in_patterns, from_files=from_files),
135
+ fields=fields,
136
+ )
137
+ code = asyncio.run(_run(request, max_calls))
138
+ if code is not ExitCode.OK:
139
+ raise SystemExit(int(code))
140
+
141
+
142
+ async def _run(request: MapRequest, max_calls: int | None) -> ExitCode:
143
+ from smartpipe.container import build_container
144
+
145
+ async with (
146
+ graceful_interrupts() as stop,
147
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
148
+ ):
149
+ code = await run_map(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
150
+ return settle_budget(container.budget, code)