nabla-cli 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/PKG-INFO +2 -1
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/__init__.py +1 -1
- nabla_cli-0.3.0/nabla/branding.py +50 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/cli.py +73 -28
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/PKG-INFO +2 -1
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/SOURCES.txt +1 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/requires.txt +1 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/pyproject.toml +2 -1
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_cli_ux.py +2 -2
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/README.md +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/config.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/contract.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/induce.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/profile.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/recorder.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/samplegen.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/term.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/setup.cfg +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_config.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_scanner.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_schema_resolver.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nabla-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
Requires-Dist: jsonschema
|
|
9
9
|
Requires-Dist: pydantic>=2
|
|
10
10
|
Requires-Dist: openai>=1.40
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
11
12
|
Provides-Extra: dev
|
|
12
13
|
Requires-Dist: pytest; extra == "dev"
|
|
13
14
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""nabla terminal branding: the ∇ logo and startup banner.
|
|
2
|
+
|
|
3
|
+
The banner prints only on a real terminal — piped output, tests, and
|
|
4
|
+
--json consumers never see it. Block glyphs render via the Windows
|
|
5
|
+
console's UTF-16 writer, so no legacy-codepage fallback is needed on an
|
|
6
|
+
interactive console.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
_LOGO = [
|
|
17
|
+
"▛▀▀▀▀▀▀▀▜",
|
|
18
|
+
" ▚ ▞",
|
|
19
|
+
" ▚ ▞",
|
|
20
|
+
" ▚ ▞",
|
|
21
|
+
" ▘",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_BRAND_STYLE = "bold cyan"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def banner_lines() -> list[tuple[str, str]]:
|
|
28
|
+
"""(logo_line, tagline) pairs; taglines are empty on the outer rows."""
|
|
29
|
+
taglines = [
|
|
30
|
+
"",
|
|
31
|
+
f"nabla v{__version__}",
|
|
32
|
+
"OpenAI call-site harvester",
|
|
33
|
+
"scan · record · profile",
|
|
34
|
+
"",
|
|
35
|
+
]
|
|
36
|
+
return list(zip(_LOGO, taglines))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def print_banner(console: Console | None = None) -> None:
|
|
40
|
+
console = console or Console()
|
|
41
|
+
if not console.is_terminal:
|
|
42
|
+
return
|
|
43
|
+
console.print()
|
|
44
|
+
for logo_line, tagline in banner_lines():
|
|
45
|
+
row = Text()
|
|
46
|
+
row.append(f" {logo_line:<12}", style=_BRAND_STYLE)
|
|
47
|
+
if tagline:
|
|
48
|
+
row.append(tagline, style="bold" if tagline.startswith("nabla") else "dim")
|
|
49
|
+
console.print(row)
|
|
50
|
+
console.print()
|
|
@@ -9,13 +9,28 @@ import sys
|
|
|
9
9
|
from getpass import getpass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .branding import print_banner
|
|
12
16
|
from .config import PROVIDERS, apply_to_env, save_config
|
|
13
|
-
from .contract import Example, is_supported
|
|
17
|
+
from .contract import Example, Verifiability, is_supported
|
|
14
18
|
from .profile import build_profile, find_site
|
|
15
19
|
from .recorder import RecordSource, ZeroTrafficError, record
|
|
16
20
|
from .scanner import scan
|
|
17
21
|
from .schema_resolver import resolve_schema
|
|
18
|
-
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_console(stderr: bool = False) -> Console:
|
|
25
|
+
console = Console(stderr=stderr, highlight=False)
|
|
26
|
+
if not console.is_terminal:
|
|
27
|
+
# Wide fixed width so captured/piped output never wraps or truncates.
|
|
28
|
+
console = Console(stderr=stderr, highlight=False, width=200)
|
|
29
|
+
return console
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_out = _make_console()
|
|
33
|
+
_errcon = _make_console(stderr=True)
|
|
19
34
|
|
|
20
35
|
_PROVIDER_DESCRIPTIONS = {
|
|
21
36
|
"openai": "real OpenAI API, uses your OPENAI_API_KEY",
|
|
@@ -37,6 +52,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
37
52
|
" nabla profile write the site profile\n"
|
|
38
53
|
),
|
|
39
54
|
)
|
|
55
|
+
parser.add_argument("--version", action="version", version=f"nabla {__version__}")
|
|
40
56
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
41
57
|
|
|
42
58
|
p_init = sub.add_parser(
|
|
@@ -116,15 +132,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
116
132
|
|
|
117
133
|
|
|
118
134
|
def _ok(msg: str) -> None:
|
|
119
|
-
print(
|
|
135
|
+
_out.print(f"✓ {msg}" if _out.is_terminal else msg, style="green", markup=False)
|
|
120
136
|
|
|
121
137
|
|
|
122
138
|
def _warn(msg: str) -> None:
|
|
123
|
-
print(
|
|
139
|
+
_errcon.print(msg, style="yellow", markup=False)
|
|
124
140
|
|
|
125
141
|
|
|
126
142
|
def _fail(msg: str) -> None:
|
|
127
|
-
print(
|
|
143
|
+
_errcon.print(msg, style="red", markup=False)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _next_step(cmd: str) -> None:
|
|
147
|
+
_out.print(f"→ next: {cmd}" if _out.is_terminal else f"next: {cmd}", style="cyan", markup=False)
|
|
128
148
|
|
|
129
149
|
|
|
130
150
|
def _unconfigured() -> bool:
|
|
@@ -140,7 +160,6 @@ def _unconfigured() -> bool:
|
|
|
140
160
|
|
|
141
161
|
|
|
142
162
|
def main(argv: list[str] | None = None) -> int:
|
|
143
|
-
init_terminal()
|
|
144
163
|
# LLM-produced text (goal descriptions, error bodies) can contain
|
|
145
164
|
# characters legacy Windows consoles (cp1252) can't encode; degrade to
|
|
146
165
|
# "?" instead of crashing mid-command.
|
|
@@ -158,6 +177,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
158
177
|
parser = _build_parser()
|
|
159
178
|
argv = sys.argv[1:] if argv is None else argv
|
|
160
179
|
if not argv:
|
|
180
|
+
print_banner(_out)
|
|
161
181
|
parser.print_help()
|
|
162
182
|
return 0
|
|
163
183
|
|
|
@@ -200,13 +220,14 @@ def _read_api_key(prompt: str) -> str:
|
|
|
200
220
|
|
|
201
221
|
|
|
202
222
|
def _cmd_init(args) -> int:
|
|
223
|
+
print_banner(_out)
|
|
203
224
|
provider = args.provider
|
|
204
225
|
if provider is None:
|
|
205
226
|
names = list(PROVIDERS)
|
|
206
|
-
print(
|
|
227
|
+
_out.print("Pick a provider:", style="bold")
|
|
207
228
|
for i, name in enumerate(names, start=1):
|
|
208
|
-
print(f" {
|
|
209
|
-
|
|
229
|
+
_out.print(f" [cyan]{i})[/cyan] [bold]{name}[/bold] — "
|
|
230
|
+
f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
|
|
210
231
|
choice = input("> ").strip()
|
|
211
232
|
if choice.isdigit() and 1 <= int(choice) <= len(names):
|
|
212
233
|
provider = names[int(choice) - 1]
|
|
@@ -234,7 +255,7 @@ def _cmd_init(args) -> int:
|
|
|
234
255
|
_ensure_scripts_on_path()
|
|
235
256
|
except Exception:
|
|
236
257
|
pass # PATH help is best-effort; setup itself already succeeded
|
|
237
|
-
|
|
258
|
+
_next_step("nabla scan (run it from inside the repo you want to harvest)")
|
|
238
259
|
return 0
|
|
239
260
|
|
|
240
261
|
|
|
@@ -300,23 +321,39 @@ def _cmd_scan(args) -> int:
|
|
|
300
321
|
if args.json:
|
|
301
322
|
print(json.dumps(rows, indent=2))
|
|
302
323
|
return 0
|
|
303
|
-
|
|
324
|
+
|
|
325
|
+
from rich import box as rich_box
|
|
326
|
+
from rich.table import Table
|
|
327
|
+
from rich.text import Text
|
|
328
|
+
|
|
329
|
+
table = Table(box=rich_box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
|
|
330
|
+
for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
|
|
331
|
+
table.add_column(col, overflow="fold")
|
|
304
332
|
supported_count = 0
|
|
305
333
|
for r in rows:
|
|
306
334
|
verif = r["verifiability"]
|
|
335
|
+
harvestable = is_supported(Verifiability(verif), r["flags"])
|
|
336
|
+
if harvestable:
|
|
337
|
+
supported_count += 1
|
|
307
338
|
if verif in ("json_schema", "pydantic_parse"):
|
|
308
339
|
verif_color = "green"
|
|
309
|
-
if not r["flags"]:
|
|
310
|
-
supported_count += 1
|
|
311
340
|
elif verif in ("tool_call", "json_object_no_schema"):
|
|
312
341
|
verif_color = "yellow"
|
|
313
342
|
else:
|
|
314
343
|
verif_color = "dim"
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
344
|
+
table.add_row(
|
|
345
|
+
Text(r["symbol"], style="cyan"),
|
|
346
|
+
r["file"],
|
|
347
|
+
r["kind"],
|
|
348
|
+
Text(verif, style=verif_color),
|
|
349
|
+
Text("yes", style="green") if harvestable else Text("no", style="dim"),
|
|
350
|
+
",".join(r["flags"]) or "-",
|
|
351
|
+
)
|
|
352
|
+
_out.print(table)
|
|
353
|
+
noun = "call site" if len(rows) == 1 else "call sites"
|
|
354
|
+
_out.print(f"{len(rows)} {noun} found — {supported_count} harvestable.", markup=False)
|
|
355
|
+
if supported_count:
|
|
356
|
+
_next_step("nabla record" + (f" {args.repo}" if args.repo != "." else ""))
|
|
320
357
|
if _unconfigured():
|
|
321
358
|
_warn("tip: run `nabla init` once to set up a provider — `nabla record` "
|
|
322
359
|
"and `nabla profile` need one")
|
|
@@ -352,7 +389,8 @@ def _resolve_site(args, sites):
|
|
|
352
389
|
|
|
353
390
|
if len(supported) == 1:
|
|
354
391
|
site = supported[0]
|
|
355
|
-
print(
|
|
392
|
+
_out.print(f"nabla: using site {site.symbol} (the only supported call site)",
|
|
393
|
+
style="dim", markup=False)
|
|
356
394
|
return site, None
|
|
357
395
|
|
|
358
396
|
candidates = supported or sites
|
|
@@ -411,11 +449,15 @@ def _cmd_record(args) -> int:
|
|
|
411
449
|
with open(out, "w", encoding="utf-8") as f:
|
|
412
450
|
for ex in examples:
|
|
413
451
|
f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
|
|
414
|
-
_ok(f"
|
|
452
|
+
_ok(f"recorded {len(examples)} (prompt, completion) pairs from "
|
|
453
|
+
f"site {site.symbol} -> {out}")
|
|
454
|
+
if source_kind == "generated_samples":
|
|
455
|
+
_warn(f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
|
|
456
|
+
"data and rerun for a higher-fidelity harvest")
|
|
415
457
|
if args.out is None and args.site is None:
|
|
416
|
-
|
|
458
|
+
_next_step(f"nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
|
|
417
459
|
else:
|
|
418
|
-
|
|
460
|
+
_next_step(f"nabla profile {args.repo} --site {site.symbol} --examples {out}")
|
|
419
461
|
return 0
|
|
420
462
|
|
|
421
463
|
|
|
@@ -492,13 +534,16 @@ def _cmd_profile(args) -> int:
|
|
|
492
534
|
out = args.out or f"{site.symbol}.profile.json"
|
|
493
535
|
Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
|
494
536
|
supported = profile["classification"]["supported"]
|
|
495
|
-
_ok(f"
|
|
496
|
-
|
|
497
|
-
|
|
537
|
+
_ok(f"wrote {out} — the handoff artifact for site {site.symbol}")
|
|
538
|
+
_out.print(f" site id {profile['site_id'][:16]}…", markup=False)
|
|
539
|
+
_out.print(f" supported {supported}", markup=False)
|
|
540
|
+
_out.print(f" examples {len(examples)} ({recorded_from})", markup=False)
|
|
541
|
+
_out.print(f" goal {goal.description[:90]}", markup=False)
|
|
542
|
+
dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
|
|
543
|
+
_out.print(f" difficulty {dims}", markup=False)
|
|
498
544
|
if len(examples) < 20:
|
|
499
|
-
_warn(f"
|
|
500
|
-
"
|
|
501
|
-
print(f"goal: {goal.description[:100]}")
|
|
545
|
+
_warn(f"only {len(examples)} examples (< 20) — enough for a look, "
|
|
546
|
+
"but a handoff-quality profile wants 20+ real-traffic pairs")
|
|
502
547
|
return 0
|
|
503
548
|
|
|
504
549
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nabla-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
Requires-Dist: jsonschema
|
|
9
9
|
Requires-Dist: pydantic>=2
|
|
10
10
|
Requires-Dist: openai>=1.40
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
11
12
|
Provides-Extra: dev
|
|
12
13
|
Requires-Dist: pytest; extra == "dev"
|
|
13
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nabla-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
|
9
9
|
"jsonschema",
|
|
10
10
|
"pydantic>=2",
|
|
11
11
|
"openai>=1.40",
|
|
12
|
+
"rich>=13.0",
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
[project.optional-dependencies]
|
|
@@ -80,7 +80,7 @@ def test_init_writes_config(tmp_path, monkeypatch, capsys):
|
|
|
80
80
|
out = capsys.readouterr().out
|
|
81
81
|
assert "gsk_test" not in out # the full key must never be echoed
|
|
82
82
|
assert "test" in out # masked last-4 tail is shown
|
|
83
|
-
assert "
|
|
83
|
+
assert "next: nabla scan" in out
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def test_init_bad_provider_exits_nonzero_with_helpful_message(capsys):
|
|
@@ -282,7 +282,7 @@ def test_profile_finds_recorded_file_by_convention(tmp_path, monkeypatch, capsys
|
|
|
282
282
|
assert (tmp_path / "extract_receipt.profile.json").exists()
|
|
283
283
|
stdout = capsys.readouterr().out
|
|
284
284
|
assert "using extract_receipt.recorded.jsonl" in stdout
|
|
285
|
-
assert "
|
|
285
|
+
assert "Extracts receipt fields." in stdout
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
def test_profile_without_recorded_file_exits_2_with_record_command(tmp_path, monkeypatch, capsys):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|