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.
Files changed (34) hide show
  1. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/PKG-INFO +2 -1
  2. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/__init__.py +1 -1
  3. nabla_cli-0.3.0/nabla/branding.py +50 -0
  4. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/cli.py +73 -28
  5. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/PKG-INFO +2 -1
  6. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/SOURCES.txt +1 -0
  7. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/requires.txt +1 -0
  8. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/pyproject.toml +2 -1
  9. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_cli_ux.py +2 -2
  10. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/README.md +0 -0
  11. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/__main__.py +0 -0
  12. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/config.py +0 -0
  13. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/contract.py +0 -0
  14. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/induce.py +0 -0
  15. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/profile.py +0 -0
  16. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/prompt_recon.py +0 -0
  17. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/recorder.py +0 -0
  18. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/samplegen.py +0 -0
  19. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/scanner.py +0 -0
  20. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/schema_resolver.py +0 -0
  21. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/schemas/site_profile.schema.json +0 -0
  22. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla/term.py +0 -0
  23. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  24. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  25. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/nabla_cli.egg-info/top_level.txt +0 -0
  26. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/setup.cfg +0 -0
  27. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_config.py +0 -0
  28. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_e2e_profile.py +0 -0
  29. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_induce.py +0 -0
  30. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_prompt_recon.py +0 -0
  31. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_recorder.py +0 -0
  32. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_samplegen.py +0 -0
  33. {nabla_cli-0.2.1 → nabla_cli-0.3.0}/tests/test_scanner.py +0 -0
  34. {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.2.1
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,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
@@ -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
- from .term import init_terminal, style
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(style(msg, "green"))
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(style(msg, "yellow", stream=sys.stderr), file=sys.stderr)
139
+ _errcon.print(msg, style="yellow", markup=False)
124
140
 
125
141
 
126
142
  def _fail(msg: str) -> None:
127
- print(style(msg, "red", stream=sys.stderr), file=sys.stderr)
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(style("Pick a provider:", "bold"))
227
+ _out.print("Pick a provider:", style="bold")
207
228
  for i, name in enumerate(names, start=1):
208
- print(f" {style(f'{i})', 'cyan')} {style(name, 'bold')} — "
209
- f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
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
- print(style("Setup complete try: nabla scan", "cyan"))
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
- print(style(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS", "bold"))
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
- flags = ",".join(r["flags"]) or "-"
316
- print(f"{style(r['symbol'], 'cyan')}{' ' * max(1, 22 - len(r['symbol']))}"
317
- f"{r['file']:<28} {r['kind']:<6} "
318
- f"{style(f'{verif:<22}', verif_color)} {flags}")
319
- print(f"\n{len(rows)} call sites found.")
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(style(f"nabla: using site {site.symbol} (the only supported call site)", "dim"))
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"nabla record: {len(examples)} examples -> {out}")
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
- print(style(f"next: nabla profile {args.repo}", "cyan"))
458
+ _next_step(f"nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
417
459
  else:
418
- print(style(f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}", "cyan"))
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"nabla profile: wrote {out} "
496
- f"(site {profile['site_id'][:12]}…, supported={supported}, "
497
- f"{len(examples)} examples)")
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"nabla profile: WARNING — {len(examples)} examples < 20; "
500
- "Phase-0-ready profiles need >=20 (cli-plan §5.4)")
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.2.1
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
 
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  nabla/__init__.py
4
4
  nabla/__main__.py
5
+ nabla/branding.py
5
6
  nabla/cli.py
6
7
  nabla/config.py
7
8
  nabla/contract.py
@@ -1,6 +1,7 @@
1
1
  jsonschema
2
2
  pydantic>=2
3
3
  openai>=1.40
4
+ rich>=13.0
4
5
 
5
6
  [dev]
6
7
  pytest
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.2.1"
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 "Setup complete — try: nabla scan" in out
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 "goal: Extracts receipt fields." in stdout
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