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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""``smartpipe sort`` — order records by a field, without the jq incantation."""
|
|
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.sortverb import SortRequest, run_sort
|
|
11
|
+
|
|
12
|
+
__all__ = ["sort_command"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(name="sort")
|
|
16
|
+
@click.option("--by", "by", required=True, metavar="FIELD", help="The field to order by.")
|
|
17
|
+
@click.option("--desc", "descending", is_flag=True, help="Largest / Z-first.")
|
|
18
|
+
def sort_command(by: str, descending: bool) -> None:
|
|
19
|
+
"""Order records by a field. Free — never calls a model. Reads the whole input.
|
|
20
|
+
|
|
21
|
+
\b
|
|
22
|
+
Examples:
|
|
23
|
+
cat scored.ndjson | smartpipe sort --by _score --desc | head -5
|
|
24
|
+
smartpipe map "…{confidence number}" --in 'docs/*.pdf' | smartpipe sort --by confidence --desc
|
|
25
|
+
|
|
26
|
+
Numbers sort numerically, strings lexically (numbers first when mixed);
|
|
27
|
+
rows missing the field always land LAST, in both directions, with a
|
|
28
|
+
note. Stable: ties keep input order. Rows pass through byte-for-byte.
|
|
29
|
+
(There is no `take` — `head` already counts NDJSON rows.)
|
|
30
|
+
"""
|
|
31
|
+
code = run_sort(SortRequest(by=by, descending=descending), stdin=sys.stdin, stdout=sys.stdout)
|
|
32
|
+
if code is not ExitCode.OK: # pragma: no cover — sort always OKs
|
|
33
|
+
raise SystemExit(int(code))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""``smartpipe split`` — break oversized items into chunk items. No model calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from smartpipe.cli.input_options import input_options, input_spec
|
|
12
|
+
from smartpipe.cli.interrupts import graceful_interrupts
|
|
13
|
+
from smartpipe.core.errors import ExitCode
|
|
14
|
+
from smartpipe.verbs.split import SplitRequest, run_split
|
|
15
|
+
|
|
16
|
+
__all__ = ["split_command"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command(name="split")
|
|
20
|
+
@click.option(
|
|
21
|
+
"--by",
|
|
22
|
+
"by_flag",
|
|
23
|
+
metavar="UNIT[:N]",
|
|
24
|
+
help="Split unit: tokens, pages, minutes, seconds. e.g. --by pages, --by minutes:10",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--media",
|
|
28
|
+
"media",
|
|
29
|
+
is_flag=True,
|
|
30
|
+
help="Extract images embedded in PDFs/DOCX/PPTX/XLSX as items (icons dropped).",
|
|
31
|
+
)
|
|
32
|
+
@click.option(
|
|
33
|
+
"--max-tokens",
|
|
34
|
+
"max_tokens",
|
|
35
|
+
type=int,
|
|
36
|
+
help="Shorthand for --by tokens:N (default 2000).",
|
|
37
|
+
)
|
|
38
|
+
@input_options
|
|
39
|
+
def split_command(
|
|
40
|
+
by_flag: str | None,
|
|
41
|
+
media: bool,
|
|
42
|
+
max_tokens: int | None,
|
|
43
|
+
in_patterns: tuple[str, ...],
|
|
44
|
+
from_files: bool,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Break oversized items into budget-sized chunks. Free — no model calls.
|
|
47
|
+
|
|
48
|
+
\b
|
|
49
|
+
Examples:
|
|
50
|
+
smartpipe split --in '10k-filings/*.pdf' | smartpipe map "list the risk factors {risk}"
|
|
51
|
+
smartpipe split --by pages:5 --in report.pdf | smartpipe map "summarize these pages"
|
|
52
|
+
smartpipe split --by minutes:10 --in call.mp3 | smartpipe map "what was agreed?"
|
|
53
|
+
|
|
54
|
+
Each chunk is a JSON record: {"text": …, "source": "report.pdf §3/12"} —
|
|
55
|
+
paragraph-boundary aware, and the chunks of a document concatenate back to
|
|
56
|
+
its exact text. Recombine downstream with reduce.
|
|
57
|
+
"""
|
|
58
|
+
request = SplitRequest(
|
|
59
|
+
max_tokens_flag=max_tokens,
|
|
60
|
+
by_flag=by_flag,
|
|
61
|
+
media=media,
|
|
62
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
63
|
+
)
|
|
64
|
+
code = asyncio.run(_run(request))
|
|
65
|
+
if code is not ExitCode.OK:
|
|
66
|
+
raise SystemExit(int(code))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _run(request: SplitRequest) -> ExitCode:
|
|
70
|
+
from smartpipe.container import build_container
|
|
71
|
+
|
|
72
|
+
async with (
|
|
73
|
+
graceful_interrupts() as stop,
|
|
74
|
+
build_container(os.environ) as container,
|
|
75
|
+
):
|
|
76
|
+
return await run_split(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""``smartpipe summarize`` — the numbers, without leaving for awk."""
|
|
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.summarize import SummarizeRequest, run_summarize
|
|
11
|
+
|
|
12
|
+
__all__ = ["summarize_command"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(name="summarize")
|
|
16
|
+
@click.argument("expression")
|
|
17
|
+
def summarize_command(expression: str) -> None:
|
|
18
|
+
"""Aggregate records deterministically. Free — never calls a model.
|
|
19
|
+
|
|
20
|
+
\b
|
|
21
|
+
Examples:
|
|
22
|
+
cat orders.jsonl | smartpipe summarize 'count(), avg(total), p95(total) by region'
|
|
23
|
+
cat evals.jsonl | smartpipe summarize 'count() by pass'
|
|
24
|
+
... | smartpipe summarize 'dcount(user) by day'
|
|
25
|
+
|
|
26
|
+
\b
|
|
27
|
+
Aggregations: count() · sum(f) · avg(f) · min(f) · max(f)
|
|
28
|
+
p50(f) · p90(f) · p95(f) · p99(f) · dcount(f)
|
|
29
|
+
|
|
30
|
+
KQL's own grammar and output naming (count, avg_total, p95_total).
|
|
31
|
+
Groups sort largest first; a missing group field groups under null,
|
|
32
|
+
visibly. Non-numeric values in numeric aggregations are skipped and
|
|
33
|
+
counted on stderr — never a mid-stream crash.
|
|
34
|
+
"""
|
|
35
|
+
code = run_summarize(SummarizeRequest(expression), stdin=sys.stdin, stdout=sys.stdout)
|
|
36
|
+
if code is not ExitCode.OK: # pragma: no cover — summarize always OKs
|
|
37
|
+
raise SystemExit(int(code))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""``smartpipe top_k`` — rank items by similarity to a query."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from smartpipe.cli.completions import complete_embed_models
|
|
12
|
+
from smartpipe.cli.input_options import fields_option, input_options, input_spec
|
|
13
|
+
from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
|
|
14
|
+
from smartpipe.core.errors import ExitCode
|
|
15
|
+
from smartpipe.verbs.top_k import TopKRequest, run_top_k
|
|
16
|
+
|
|
17
|
+
__all__ = ["top_k_command"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command(name="top_k")
|
|
21
|
+
@click.argument("k", type=int, required=False)
|
|
22
|
+
@click.option("--near", required=True, help="Rank items by similarity to this query.")
|
|
23
|
+
@click.option("--threshold", type=float, help="Keep everything at or above this similarity (0-1).")
|
|
24
|
+
@click.option(
|
|
25
|
+
"--embed-model", "model_flag", shell_complete=complete_embed_models, help="Embedding model."
|
|
26
|
+
)
|
|
27
|
+
@click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
|
|
28
|
+
@click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
|
|
29
|
+
@click.option("--stream", "stream", is_flag=True, help="Live leaderboard over a stream.")
|
|
30
|
+
@fields_option
|
|
31
|
+
@click.option(
|
|
32
|
+
"--allow-captions",
|
|
33
|
+
"allow_captions",
|
|
34
|
+
is_flag=True,
|
|
35
|
+
help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
|
|
36
|
+
)
|
|
37
|
+
@input_options
|
|
38
|
+
def top_k_command(
|
|
39
|
+
k: int | None,
|
|
40
|
+
near: str,
|
|
41
|
+
threshold: float | None,
|
|
42
|
+
model_flag: str | None,
|
|
43
|
+
concurrency_flag: int | None,
|
|
44
|
+
max_calls: int | None,
|
|
45
|
+
allow_captions: bool,
|
|
46
|
+
stream: bool,
|
|
47
|
+
fields: tuple[str, ...] | None,
|
|
48
|
+
in_patterns: tuple[str, ...],
|
|
49
|
+
from_files: bool,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Rank items by similarity to a query and return the top K.
|
|
52
|
+
|
|
53
|
+
\b
|
|
54
|
+
Examples:
|
|
55
|
+
smartpipe top_k 5 --near "distributed systems engineer" --in 'resumes/*.pdf'
|
|
56
|
+
cat corpus.embeddings | smartpipe top_k 10 --near "Q3 revenue strategy"
|
|
57
|
+
cat articles.jsonl | smartpipe top_k --near "climate policy" --threshold 0.8
|
|
58
|
+
|
|
59
|
+
Give a number (K), a --threshold, or both. Each result gains a _score (0-1).
|
|
60
|
+
In file mode, each result is a filename and its score.
|
|
61
|
+
"""
|
|
62
|
+
request = TopKRequest(
|
|
63
|
+
allow_captions=allow_captions,
|
|
64
|
+
near=near,
|
|
65
|
+
k=k,
|
|
66
|
+
threshold=threshold,
|
|
67
|
+
model_flag=model_flag,
|
|
68
|
+
concurrency_flag=concurrency_flag,
|
|
69
|
+
stream=stream,
|
|
70
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
71
|
+
fields=fields,
|
|
72
|
+
)
|
|
73
|
+
code = asyncio.run(_run(request, max_calls))
|
|
74
|
+
if code is not ExitCode.OK:
|
|
75
|
+
raise SystemExit(int(code))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _run(request: TopKRequest, max_calls: int | None) -> ExitCode:
|
|
79
|
+
from smartpipe.container import build_container
|
|
80
|
+
|
|
81
|
+
if not request.stream: # whole-set mode: ^C exits immediately; budget is fatal (D18)
|
|
82
|
+
async with build_container(os.environ, max_calls=max_calls) as container:
|
|
83
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
84
|
+
from dataclasses import replace as _replace
|
|
85
|
+
|
|
86
|
+
request = _replace(request, allow_captions=True) # profile consent (D35)
|
|
87
|
+
return await run_top_k(request, container, stdin=sys.stdin, stdout=sys.stdout)
|
|
88
|
+
async with (
|
|
89
|
+
graceful_interrupts() as stop,
|
|
90
|
+
build_container(os.environ, max_calls=max_calls, stop=stop) as container,
|
|
91
|
+
):
|
|
92
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
93
|
+
from dataclasses import replace as _replace
|
|
94
|
+
|
|
95
|
+
request = _replace(request, allow_captions=True) # profile consent (D35)
|
|
96
|
+
code = await run_top_k(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
97
|
+
return settle_budget(container.budget, code)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``smartpipe usage`` — the ledger of what the meter observed (D41)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from smartpipe.io.metering import count as count_fmt
|
|
10
|
+
from smartpipe.io.metering import duration as duration_fmt
|
|
11
|
+
from smartpipe.io.metering import megabytes as megabytes_fmt
|
|
12
|
+
from smartpipe.io.usage import Totals, read_ledger, reset_ledger, stamp
|
|
13
|
+
|
|
14
|
+
__all__ = ["usage_command"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group(name="usage", invoke_without_command=True)
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def usage_command(ctx: click.Context) -> None:
|
|
20
|
+
"""Model usage over time: hour, day, week, month, lifetime. Resettable.
|
|
21
|
+
|
|
22
|
+
Counts model-touching runs only (free verbs never call a model). The
|
|
23
|
+
numbers are the meter's observed units — tokens, media, conversions.
|
|
24
|
+
"""
|
|
25
|
+
if ctx.invoked_subcommand is not None:
|
|
26
|
+
return
|
|
27
|
+
windows, first_seen, last_reset = read_ledger(os.environ)
|
|
28
|
+
if windows["lifetime"].runs == 0:
|
|
29
|
+
click.echo("no model usage recorded yet")
|
|
30
|
+
return
|
|
31
|
+
from smartpipe.cli.screens import heading, tint
|
|
32
|
+
|
|
33
|
+
header = (
|
|
34
|
+
f"{'':<12}{'runs':>6}{'tokens in':>12}{'tokens out':>12}"
|
|
35
|
+
f"{'media':>10}{'audio':>9}{'conv':>6}"
|
|
36
|
+
)
|
|
37
|
+
click.echo(heading(header))
|
|
38
|
+
for name in ("past hour", "past day", "past week", "past month", "lifetime"):
|
|
39
|
+
row = _row(name, windows[name])
|
|
40
|
+
click.echo(tint(row, "1") if name == "lifetime" else row)
|
|
41
|
+
notes: list[str] = []
|
|
42
|
+
if last_reset is not None:
|
|
43
|
+
notes.append(f"last reset {stamp(last_reset)}")
|
|
44
|
+
if first_seen is not None:
|
|
45
|
+
notes.append(f"first use {stamp(first_seen)}")
|
|
46
|
+
if notes:
|
|
47
|
+
click.echo(tint("since: " + " · ".join(notes), "2"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _row(name: str, totals: Totals) -> str:
|
|
51
|
+
audio = duration_fmt(totals.audio_seconds) if totals.audio_seconds else "-"
|
|
52
|
+
media = megabytes_fmt(totals.media_bytes) if totals.media_bytes else "-"
|
|
53
|
+
return (
|
|
54
|
+
f"{name:<12}{totals.runs:>6}{count_fmt(totals.tokens_in):>12}"
|
|
55
|
+
f"{count_fmt(totals.tokens_out):>12}{media:>10}{audio:>9}{totals.conversions:>6}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@usage_command.command(name="reset")
|
|
60
|
+
def usage_reset() -> None:
|
|
61
|
+
"""Zero the ledger; the reset time is remembered and shown."""
|
|
62
|
+
previous = reset_ledger(os.environ)
|
|
63
|
+
click.echo(
|
|
64
|
+
f"usage reset (previous lifetime: {count_fmt(previous.tokens_in)} in · "
|
|
65
|
+
f"{count_fmt(previous.tokens_out)} out tokens across {previous.runs} runs)"
|
|
66
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""``smartpipe where`` — the free filter. Runs before anything paid."""
|
|
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.where import WhereRequest, run_where
|
|
11
|
+
|
|
12
|
+
__all__ = ["where_command"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(name="where")
|
|
16
|
+
@click.argument("predicate")
|
|
17
|
+
def where_command(predicate: str) -> None:
|
|
18
|
+
"""Keep rows matching a deterministic predicate. Free — never calls a model.
|
|
19
|
+
|
|
20
|
+
\b
|
|
21
|
+
Examples:
|
|
22
|
+
tail -f app.log | smartpipe where 'text has "ERROR"'
|
|
23
|
+
cat orders.jsonl | smartpipe where 'total > 1000'
|
|
24
|
+
smartpipe where 'level == "error" and not text contains "retry"'
|
|
25
|
+
|
|
26
|
+
\b
|
|
27
|
+
Operators: has (word, case-insensitive) · contains · matches /re/
|
|
28
|
+
== != > >= < <= combined with and · or · not · ( )
|
|
29
|
+
FIELD is a record field, or text for the whole line.
|
|
30
|
+
|
|
31
|
+
Put where BEFORE filter/map: it cuts the corpus for free, so the paid
|
|
32
|
+
stages only see what matters. Semantic condition? Use filter.
|
|
33
|
+
"""
|
|
34
|
+
code = run_where(WhereRequest(predicate), stdin=sys.stdin, stdout=sys.stdout)
|
|
35
|
+
if code is not ExitCode.OK: # pragma: no cover — where currently always OKs
|
|
36
|
+
raise SystemExit(int(code))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The OAuth token store (plan/decisions.md D19) — ``auth.json`` beside the config.
|
|
2
|
+
|
|
3
|
+
The narrowest amendment to "nothing stored": one file, mode 0600, one purpose
|
|
4
|
+
(login tokens that must persist to refresh), removable with ``smartpipe auth
|
|
5
|
+
logout``. API keys never live here. Unknown provider entries are preserved on
|
|
6
|
+
rewrite (forward compatibility, same spirit as the config store).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from smartpipe.config.paths import human_path
|
|
20
|
+
from smartpipe.core.errors import SetupFault
|
|
21
|
+
from smartpipe.core.jsontools import as_record, as_str
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
__all__ = ["OAuthCredential", "credentials_path", "load_oauth", "remove_oauth", "save_oauth"]
|
|
27
|
+
|
|
28
|
+
_MODE = 0o600
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class OAuthCredential:
|
|
33
|
+
access: str
|
|
34
|
+
refresh: str
|
|
35
|
+
expires_ms: int # epoch milliseconds, matching the wire's convention
|
|
36
|
+
account_id: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def credentials_path(env: Mapping[str, str] | None = None, platform: str | None = None) -> Path:
|
|
40
|
+
from smartpipe.config.paths import config_path
|
|
41
|
+
|
|
42
|
+
return config_path(env, platform).parent / "auth.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_oauth(path: Path, provider: str) -> OAuthCredential | None:
|
|
46
|
+
record = _read_raw(path).get(provider)
|
|
47
|
+
entry = as_record(record)
|
|
48
|
+
if entry is None or entry.get("type") != "oauth":
|
|
49
|
+
return None
|
|
50
|
+
access = as_str(entry.get("access"))
|
|
51
|
+
refresh = as_str(entry.get("refresh"))
|
|
52
|
+
expires = entry.get("expires")
|
|
53
|
+
if access is None or refresh is None or not isinstance(expires, int):
|
|
54
|
+
return None
|
|
55
|
+
return OAuthCredential(
|
|
56
|
+
access=access,
|
|
57
|
+
refresh=refresh,
|
|
58
|
+
expires_ms=expires,
|
|
59
|
+
account_id=as_str(entry.get("account_id")),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_oauth(path: Path, provider: str, credential: OAuthCredential) -> None:
|
|
64
|
+
data = _read_raw(path)
|
|
65
|
+
entry: dict[str, object] = {
|
|
66
|
+
"type": "oauth",
|
|
67
|
+
"access": credential.access,
|
|
68
|
+
"refresh": credential.refresh,
|
|
69
|
+
"expires": credential.expires_ms,
|
|
70
|
+
}
|
|
71
|
+
if credential.account_id is not None:
|
|
72
|
+
entry["account_id"] = credential.account_id
|
|
73
|
+
data[provider] = entry
|
|
74
|
+
_write_raw(path, data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def remove_oauth(path: Path, provider: str) -> bool:
|
|
78
|
+
"""Delete one provider's entry; True if something was removed."""
|
|
79
|
+
data = _read_raw(path)
|
|
80
|
+
if provider not in data:
|
|
81
|
+
return False
|
|
82
|
+
del data[provider]
|
|
83
|
+
_write_raw(path, data)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_raw(path: Path) -> dict[str, object]:
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return {}
|
|
90
|
+
try:
|
|
91
|
+
parsed: object = json.loads(path.read_text(encoding="utf-8"))
|
|
92
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
93
|
+
raise SetupFault(
|
|
94
|
+
f"error: the login store is unreadable\n"
|
|
95
|
+
f" {human_path(path)}: {exc}\n"
|
|
96
|
+
" Fix the file, or remove it and log in again: smartpipe auth login"
|
|
97
|
+
) from exc
|
|
98
|
+
record = as_record(parsed)
|
|
99
|
+
return dict(record) if record is not None else {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_raw(path: Path, data: dict[str, object]) -> None:
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
105
|
+
try:
|
|
106
|
+
if hasattr(os, "fchmod"): # POSIX: 0600 from the first byte
|
|
107
|
+
os.fchmod(fd, _MODE)
|
|
108
|
+
else: # Windows has no fd-chmod; ACLs scope the profile dir instead
|
|
109
|
+
os.chmod(tmp, _MODE)
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
111
|
+
json.dump(data, handle, indent=2)
|
|
112
|
+
os.replace(tmp, path) # atomic on POSIX and Windows (same volume)
|
|
113
|
+
except BaseException:
|
|
114
|
+
with contextlib.suppress(OSError):
|
|
115
|
+
os.unlink(tmp)
|
|
116
|
+
raise
|
|
117
|
+
with contextlib.suppress(OSError): # replace preserves the temp's mode, but be sure
|
|
118
|
+
os.chmod(path, _MODE)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Rendering ``config show`` — pure, so the exact layout is golden-testable.
|
|
2
|
+
|
|
3
|
+
The point of the origin column (plan/decisions.md D09): precedence is never a
|
|
4
|
+
mystery. Each effective value is shown with where it came from — env var,
|
|
5
|
+
config file, or built-in default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from smartpipe.io.text import display_width
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from smartpipe.config.store import Config
|
|
19
|
+
|
|
20
|
+
__all__ = ["Setting", "render_show", "settings_with_origin"]
|
|
21
|
+
|
|
22
|
+
_DEFAULTS = {
|
|
23
|
+
"model": "(auto-detect)",
|
|
24
|
+
"embed-model": "nomic-embed-text",
|
|
25
|
+
"concurrency": "4",
|
|
26
|
+
"output": "auto",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Setting:
|
|
32
|
+
key: str
|
|
33
|
+
value: str
|
|
34
|
+
origin: str # "env" | "config file" | "default"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def settings_with_origin(env: Mapping[str, str], config: Config) -> tuple[Setting, ...]:
|
|
38
|
+
return (
|
|
39
|
+
_resolve("model", env.get("SMARTPIPE_MODEL"), config.model),
|
|
40
|
+
_resolve("embed-model", env.get("SMARTPIPE_EMBED_MODEL"), config.embed_model),
|
|
41
|
+
_resolve("concurrency", env.get("SMARTPIPE_CONCURRENCY"), config.concurrency),
|
|
42
|
+
_resolve("output", env.get("SMARTPIPE_OUTPUT"), config.output),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_show(settings: Sequence[Setting], config_file: str) -> str:
|
|
47
|
+
key_width = max(display_width(s.key) for s in (*settings, Setting("config file", "", ""))) + 2
|
|
48
|
+
value_width = max(display_width(s.value) for s in settings) + 2
|
|
49
|
+
from smartpipe.cli.screens import tint
|
|
50
|
+
|
|
51
|
+
lines = [
|
|
52
|
+
f"{tint(_pad(s.key, key_width), '2')}{_pad(s.value, value_width)}"
|
|
53
|
+
f"{tint(f'({s.origin})', '2')}"
|
|
54
|
+
for s in settings
|
|
55
|
+
]
|
|
56
|
+
lines.append(f"{tint(_pad('config file', key_width), '2')}{config_file}")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pad(text: str, width: int) -> str:
|
|
61
|
+
"""Pad to ``width`` terminal cells (DEFER-2) — f-string ``<`` pads code points."""
|
|
62
|
+
return text + " " * max(0, width - display_width(text))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve(key: str, env_value: str | None, config_value: object) -> Setting:
|
|
66
|
+
if env_value is not None and env_value.strip():
|
|
67
|
+
return Setting(key, env_value.strip(), "env")
|
|
68
|
+
if config_value is not None:
|
|
69
|
+
return Setting(key, str(config_value), "config file")
|
|
70
|
+
return Setting(key, _DEFAULTS[key], "default")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``smartpipe doctor`` report — the pure half (D18).
|
|
2
|
+
|
|
3
|
+
Check *gathering* is the CLI's job (it touches env, disk, and the local Ollama
|
|
4
|
+
probe); this module owns the result type, the rendering, and the exit rule so the
|
|
5
|
+
exact screen is golden-testable. Doctor never makes a paid model call — its whole
|
|
6
|
+
point is telling you the run will work *before* anything costs money.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Literal
|
|
13
|
+
|
|
14
|
+
from smartpipe.core.errors import ExitCode
|
|
15
|
+
from smartpipe.io.text import display_width
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
|
|
20
|
+
__all__ = ["CheckResult", "doctor_exit_code", "render_report"]
|
|
21
|
+
|
|
22
|
+
_MARKS: dict[str, str] = {"ok": "✓", "fail": "✗", "skip": "–"} # noqa: RUF001 — the en-dash skip mark is the ux.md pin
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class CheckResult:
|
|
27
|
+
section: str # "config", "ollama", "chat", "embed", "keys", "login", "extras", "terminal"
|
|
28
|
+
status: Literal["ok", "fail", "skip"]
|
|
29
|
+
detail: str # the rest of the line, including any "— fix: …"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_report(results: Sequence[CheckResult]) -> str:
|
|
33
|
+
"""One line per check: padded section, mark, detail (the ux.md doctor screen)."""
|
|
34
|
+
from smartpipe.cli.screens import bad, good, tint
|
|
35
|
+
|
|
36
|
+
width = max(display_width(result.section) for result in results) + 2
|
|
37
|
+
|
|
38
|
+
def dim_mark(mark: str) -> str:
|
|
39
|
+
return tint(mark, "2")
|
|
40
|
+
|
|
41
|
+
paint: dict[str, Callable[[str], str]] = {"ok": good, "fail": bad, "skip": dim_mark}
|
|
42
|
+
lines = (
|
|
43
|
+
f"{tint(_pad(result.section, width), '2')}"
|
|
44
|
+
f"{paint[result.status](_MARKS[result.status])} {result.detail}"
|
|
45
|
+
for result in results
|
|
46
|
+
)
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def doctor_exit_code(results: Sequence[CheckResult]) -> ExitCode:
|
|
51
|
+
"""0 all green (skips are fine); 1 if anything needs fixing."""
|
|
52
|
+
if any(result.status == "fail" for result in results):
|
|
53
|
+
return ExitCode.PARTIAL
|
|
54
|
+
return ExitCode.OK
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pad(text: str, width: int) -> str:
|
|
58
|
+
return text + " " * max(0, width - display_width(text))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Where the config file lives (plan/decisions.md D09).
|
|
2
|
+
|
|
3
|
+
``~/.config/smartpipe/config.toml`` on every Unix including macOS — the spec
|
|
4
|
+
names this path and terminal users expect it — and ``%APPDATA%\\smartpipe`` on
|
|
5
|
+
Windows. Environment and platform are parameters so the logic is pure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
__all__ = ["config_path", "human_path"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_path(env: Mapping[str, str] | None = None, platform: str | None = None) -> Path:
|
|
22
|
+
resolved_env = os.environ if env is None else env
|
|
23
|
+
resolved_platform = sys.platform if platform is None else platform
|
|
24
|
+
if resolved_platform == "win32":
|
|
25
|
+
appdata = resolved_env.get("APPDATA")
|
|
26
|
+
base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
|
27
|
+
else:
|
|
28
|
+
xdg = resolved_env.get("XDG_CONFIG_HOME")
|
|
29
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
30
|
+
return base / "smartpipe" / "config.toml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def human_path(path: Path) -> str:
|
|
34
|
+
"""``~/.config/smartpipe/config.toml`` beats an absolute path in messages."""
|
|
35
|
+
try:
|
|
36
|
+
return f"~/{path.relative_to(Path.home()).as_posix()}"
|
|
37
|
+
except ValueError:
|
|
38
|
+
return str(path)
|