nabla-cli 0.6.2__tar.gz → 0.6.3__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 (35) hide show
  1. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/PKG-INFO +1 -1
  2. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/__init__.py +1 -1
  3. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/cli.py +52 -46
  4. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/recorder.py +41 -27
  5. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/scanner.py +2 -1
  6. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/ui.py +43 -0
  7. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/PKG-INFO +1 -1
  8. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/pyproject.toml +1 -1
  9. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_cli_ux.py +51 -0
  10. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/README.md +0 -0
  11. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/__main__.py +0 -0
  12. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/branding.py +0 -0
  13. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/config.py +0 -0
  14. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/contract.py +0 -0
  15. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/induce.py +0 -0
  16. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/profile.py +0 -0
  17. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/prompt_recon.py +0 -0
  18. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/samplegen.py +0 -0
  19. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/schema_resolver.py +0 -0
  20. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/schemas/site_profile.schema.json +0 -0
  21. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/term.py +0 -0
  22. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/SOURCES.txt +0 -0
  23. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/dependency_links.txt +0 -0
  24. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/entry_points.txt +0 -0
  25. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/requires.txt +0 -0
  26. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/top_level.txt +0 -0
  27. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/setup.cfg +0 -0
  28. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_config.py +0 -0
  29. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_e2e_profile.py +0 -0
  30. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_induce.py +0 -0
  31. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_prompt_recon.py +0 -0
  32. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_recorder.py +0 -0
  33. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_samplegen.py +0 -0
  34. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_scanner.py +0 -0
  35. {nabla_cli-0.6.2 → nabla_cli-0.6.3}/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.6.2
3
+ Version: 0.6.3
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
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.6.2"
3
+ __version__ = "0.6.3"
@@ -493,19 +493,9 @@ def _cmd_record(args) -> int:
493
493
  elif args.proxy_cmd:
494
494
  source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
495
495
  else:
496
- conventional = next(
497
- (p for p in (Path(f"{site.symbol}.inputs.jsonl"),
498
- Path(f"{site.symbol}.samples.jsonl")) # legacy name
499
- if p.exists()),
500
- None,
501
- )
502
- if conventional is not None:
503
- rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
504
- source = RecordSource(kind="samples", samples_file=str(conventional))
505
- else:
506
- source, source_kind, err = _resolve_inputs_interactively(args, site)
507
- if err is not None:
508
- return err
496
+ source, source_kind, err = _resolve_inputs_interactively(args, site)
497
+ if err is not None:
498
+ return err
509
499
 
510
500
  from .ui import make_progress, rail_end, rail_ok
511
501
 
@@ -554,46 +544,60 @@ def _cmd_record(args) -> int:
554
544
  return 0
555
545
 
556
546
 
557
- def _inputs_menu(label: str) -> tuple[str, object]:
558
- """The interactive inputs menu. Returns ("file", path) or ("generate", n)."""
559
- from .ui import prompt_text, select
547
+ def _inputs_menu(label: str, existing: Path | None = None) -> tuple[str, object]:
548
+ """The interactive inputs menu shown every capture, so reusing a
549
+ previous inputs file is a choice, not an ambush. Returns ("file", path)
550
+ or ("generate", n)."""
551
+ from .ui import prompt_digits, prompt_text, select
560
552
 
561
- options = [
553
+ options = []
554
+ if existing is not None:
555
+ try:
556
+ count = sum(1 for line in existing.read_text(encoding="utf-8").splitlines()
557
+ if line.strip())
558
+ except OSError:
559
+ count = "?"
560
+ options.append(("use existing", f"{existing} · {count} inputs"))
561
+ options += [
562
562
  ("generate 5", "synthetic inputs — quick look, fewest credits"),
563
563
  ("generate 10", "synthetic inputs — balanced"),
564
564
  ("generate 20", "synthetic inputs — handoff floor, most credits"),
565
565
  ("custom count", "type how many synthetic inputs to generate"),
566
566
  ("my own file", "type a path to your JSONL of inputs"),
567
567
  ]
568
+ shift = 1 if existing is not None else 0
568
569
  choice = select(_out, label, options)
569
- if choice == 3:
570
- while True:
571
- typed = prompt_text(_out, "How many", default="5")
572
- if typed.isdigit() and int(typed) > 0:
573
- return "generate", int(typed)
574
- _warn("enter a positive number")
575
- if choice == 4:
570
+ if existing is not None and choice == 0:
571
+ return "file", str(existing)
572
+ if choice == shift + 3:
573
+ return "generate", prompt_digits(_out, "How many", default=5)
574
+ if choice == shift + 4:
576
575
  while True:
577
576
  typed = prompt_text(_out, "Path to JSONL")
578
577
  if typed and Path(typed).exists():
579
578
  return "file", typed
580
579
  _warn(f"{typed or '(empty)'} not found — try again, or Ctrl+C to abort")
581
- return "generate", (5, 10, 20)[choice]
580
+ return "generate", (5, 10, 20)[choice - shift]
582
581
 
583
582
 
584
583
  def _resolve_inputs_interactively(args, site):
585
- """No --inputs given and no conventional file found. Interactively, ask
586
- where inputs come from (generate N / custom count / own file); otherwise
587
- generate the default quietly. Returns (source, source_kind, exit_code)
588
- with exit_code None on success."""
589
- n = args.num_samples
590
- if n is None and _out.is_terminal and sys.stdin.isatty():
591
- kind, value = _inputs_menu("Inputs")
584
+ """No --inputs given. Interactively, ask where inputs come from (existing
585
+ file / generate N / custom count / own file); otherwise use an existing
586
+ conventional file or generate the default quietly. Returns
587
+ (source, source_kind, exit_code) with exit_code None on success."""
588
+ conventional = _find_conventional_inputs(site)
589
+
590
+ if args.num_samples is None and _out.is_terminal and sys.stdin.isatty():
591
+ kind, value = _inputs_menu("Inputs", existing=conventional)
592
592
  if kind == "file":
593
593
  return RecordSource(kind="samples", samples_file=value), None, None
594
594
  n = value
595
- elif n is None:
596
- n = 5
595
+ elif conventional is not None and args.num_samples is None:
596
+ from .ui import rail
597
+ rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
598
+ return RecordSource(kind="samples", samples_file=str(conventional)), None, None
599
+ else:
600
+ n = args.num_samples or 5
597
601
 
598
602
  source, err = _generate_samples_source(args, site, Path(f"{site.symbol}.inputs.jsonl"), n)
599
603
  if err is not None:
@@ -637,25 +641,27 @@ def _capture_many(args, supported) -> int:
637
641
  rail(_out, f"capturing all {len(targets)} supported call sites")
638
642
 
639
643
  # Phase 1 — decide inputs per site, sequentially (menus can't overlap).
644
+ # The menu is shown every time; an existing inputs file is offered as
645
+ # the default option, never silently assumed.
640
646
  plans: list[dict] = []
641
647
  for site in targets:
642
648
  conventional = _find_conventional_inputs(site)
643
- if conventional is not None:
649
+ if args.num_samples is None and interactive:
650
+ kind, value = _inputs_menu(f"Inputs · {site.symbol}", existing=conventional)
651
+ if kind == "file":
652
+ plans.append({"site": site, "kind": "file", "path": value,
653
+ "source_kind": "samples"})
654
+ else:
655
+ plans.append({"site": site, "kind": "generate", "n": value,
656
+ "source_kind": "generated_samples"})
657
+ continue
658
+ if conventional is not None and args.num_samples is None:
644
659
  rail(_out, f"{site.symbol}: using {conventional}")
645
660
  plans.append({"site": site, "kind": "file", "path": str(conventional),
646
661
  "source_kind": "samples"})
647
662
  continue
648
- if args.num_samples is not None or not interactive:
649
- plans.append({"site": site, "kind": "generate",
650
- "n": args.num_samples or 5, "source_kind": "generated_samples"})
651
- continue
652
- kind, value = _inputs_menu(f"Inputs · {site.symbol}")
653
- if kind == "file":
654
- plans.append({"site": site, "kind": "file", "path": value,
655
- "source_kind": "samples"})
656
- else:
657
- plans.append({"site": site, "kind": "generate", "n": value,
658
- "source_kind": "generated_samples"})
663
+ plans.append({"site": site, "kind": "generate",
664
+ "n": args.num_samples or 5, "source_kind": "generated_samples"})
659
665
 
660
666
  # Phase 2 — run all captures in parallel.
661
667
  progress = make_progress(_out) if _out.is_terminal else None
@@ -131,42 +131,56 @@ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
131
131
  model_override = os.environ.get("NABLA_UPSTREAM_MODEL")
132
132
  if model_override:
133
133
  body = {**body, "model": model_override}
134
- req = urllib.request.Request(
135
- f"{base_url}/chat/completions",
136
- data=json.dumps(body).encode("utf-8"),
137
- method="POST",
138
- headers={
139
- "Content-Type": "application/json",
140
- "Authorization": auth,
141
- # Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
142
- # urllib's default Python-urllib User-Agent with a 403.
143
- "User-Agent": "nabla-recorder/0.1",
144
- },
145
- )
134
+ request_headers = {
135
+ "Content-Type": "application/json",
136
+ "Authorization": auth,
137
+ # Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
138
+ # urllib's default Python-urllib User-Agent with a 403.
139
+ "User-Agent": "nabla-recorder/0.1",
140
+ }
146
141
  # Rate limits (429) and transient upstream errors are expected during a
147
142
  # long record run, especially on free-tier stand-in oracles: back off and
148
- # retry, honoring Retry-After when the upstream sends one.
143
+ # retry, honoring Retry-After when the upstream sends one. A stand-in
144
+ # that rejects the site's json_schema outright (some providers support a
145
+ # narrower schema dialect, e.g. no type arrays) gets one retry in
146
+ # json_object mode — the production schema in the profile is unaffected.
149
147
  delays = [5, 10, 20, 30, 45]
150
- for attempt, delay in enumerate([*delays, None]):
148
+ attempt = 0
149
+ fell_back = False
150
+ while True:
151
+ req = urllib.request.Request(
152
+ f"{base_url}/chat/completions",
153
+ data=json.dumps(body).encode("utf-8"),
154
+ method="POST",
155
+ headers=request_headers,
156
+ )
151
157
  try:
152
158
  with urllib.request.urlopen(req, timeout=60) as resp:
153
159
  return json.loads(resp.read().decode("utf-8"))
154
160
  except urllib.error.HTTPError as exc:
155
- if delay is None or exc.code not in (429, 500, 502, 503):
156
- # Surface the upstream's error body — "HTTP 400" alone is
157
- # undiagnosable (e.g. Groq's json_validate_failed).
158
- try:
159
- detail = exc.read().decode("utf-8", "replace")[:500]
160
- except Exception: # noqa: BLE001 - body read is best-effort
161
- detail = ""
161
+ try:
162
+ detail = exc.read().decode("utf-8", "replace")[:500]
163
+ except Exception: # noqa: BLE001 - body read is best-effort
164
+ detail = ""
165
+ rf = body.get("response_format")
166
+ if (exc.code == 400 and model_override and not fell_back
167
+ and isinstance(rf, dict) and rf.get("type") == "json_schema"
168
+ and ("schema" in detail.lower() or "invalid" in detail.lower())):
169
+ body = {**body, "response_format": {"type": "json_object"}}
170
+ fell_back = True
171
+ print("nabla capture: stand-in oracle rejected this site's json_schema — "
172
+ "retrying in json_object mode (the profile still carries the real schema)",
173
+ file=sys.stderr)
174
+ continue
175
+ if attempt >= len(delays) or exc.code not in (429, 500, 502, 503):
162
176
  raise RuntimeError(f"upstream HTTP {exc.code}: {detail or exc.reason}") from exc
163
177
  retry_after = exc.headers.get("Retry-After") if exc.headers else None
164
178
  try:
165
- wait = max(float(retry_after), delay) if retry_after else delay
179
+ wait = max(float(retry_after), delays[attempt]) if retry_after else delays[attempt]
166
180
  except ValueError:
167
- wait = delay
181
+ wait = delays[attempt]
168
182
  time.sleep(min(wait, 60))
169
- raise RuntimeError("unreachable")
183
+ attempt += 1
170
184
 
171
185
 
172
186
  def _default_responder() -> Responder:
@@ -415,10 +429,10 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
415
429
  timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
416
430
  proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
417
431
  if proc.returncode != 0:
418
- # The last stderr lines carry the actual exception; the rest is
419
- # traceback scaffolding that buries it.
432
+ # The last stderr line carries the actual exception; everything above
433
+ # it is traceback scaffolding that buries the signal.
420
434
  lines = [l for l in (proc.stderr or proc.stdout or "").splitlines() if l.strip()]
421
- detail = " | ".join(lines[-2:]) if lines else "no output"
435
+ detail = lines[-1].strip() if lines else "no output"
422
436
  raise RuntimeError(
423
437
  f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
424
438
  f"(exit {proc.returncode}): {detail}"
@@ -40,7 +40,8 @@ from .contract import (
40
40
  RawSiteRef,
41
41
  )
42
42
 
43
- _SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv"}
43
+ _SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv",
44
+ "build", "dist", ".eggs", ".tox", ".mypy_cache"}
44
45
 
45
46
  _VISION_MARKERS = {"image_url", "input_image"}
46
47
 
@@ -267,3 +267,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
267
267
  console=console,
268
268
  transient=True,
269
269
  )
270
+
271
+
272
+ def prompt_digits(console: Console, label: str, default: int) -> int:
273
+ """Numeric input that cannot go wrong: on an interactive Windows console
274
+ it reads keys raw — digits echo, backspace works, everything else
275
+ (arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
276
+ it strips non-digits from a plain input()."""
277
+ prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
278
+ prompt.append(label, style="bold")
279
+ prompt.append(f" [{default}]", style="cyan")
280
+ prompt.append(" ")
281
+
282
+ interactive = (
283
+ sys.platform == "win32"
284
+ and console.is_terminal
285
+ and sys.stdin.isatty()
286
+ )
287
+ if not interactive:
288
+ console.print(prompt, end="")
289
+ raw = "".join(ch for ch in input() if ch.isdigit())
290
+ return int(raw) if raw else default
291
+
292
+ import msvcrt
293
+
294
+ console.print(prompt, end="")
295
+ chars: list[str] = []
296
+ while True:
297
+ ch = msvcrt.getwch()
298
+ if ch in ("\r", "\n"):
299
+ print()
300
+ break
301
+ if ch == "\x03":
302
+ raise KeyboardInterrupt
303
+ if ch == "\b":
304
+ if chars:
305
+ chars.pop()
306
+ print("\b \b", end="", flush=True)
307
+ elif ch.isdigit():
308
+ chars.append(ch)
309
+ print(ch, end="", flush=True)
310
+ elif ch in ("\xe0", "\x00"):
311
+ msvcrt.getwch() # swallow the arrow/function-key payload
312
+ return int("".join(chars)) if chars else default
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.6.2
3
+ Version: 0.6.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.6.2"
3
+ version = "0.6.3"
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" }
@@ -466,3 +466,54 @@ def test_ctrl_c_is_one_quiet_line(monkeypatch, capsys):
466
466
  captured = capsys.readouterr()
467
467
  assert "cancelled" in captured.err
468
468
  assert "Traceback" not in captured.err + captured.out
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # 0.6.3 fixes: digit prompt, always-shown menu, scan skips build dirs
473
+ # ---------------------------------------------------------------------------
474
+
475
+ def test_prompt_digits_noninteractive_strips_garbage(monkeypatch):
476
+ from rich.console import Console
477
+
478
+ from nabla.ui import prompt_digits
479
+
480
+ console = Console(width=100)
481
+ monkeypatch.setattr("builtins.input", lambda *a: "[1]")
482
+ assert prompt_digits(console, "How many", default=5) == 1
483
+
484
+ monkeypatch.setattr("builtins.input", lambda *a: "")
485
+ assert prompt_digits(console, "How many", default=5) == 5
486
+
487
+
488
+ def test_inputs_menu_offers_existing_file(tmp_path, monkeypatch, capsys):
489
+ from nabla.cli import _inputs_menu
490
+
491
+ existing = tmp_path / "site.inputs.jsonl"
492
+ existing.write_text('{"input_slots": {"q": "x"}}\n' * 3, encoding="utf-8")
493
+ picked = {}
494
+
495
+ def fake_select(console, label, options):
496
+ picked["options"] = options
497
+ return 0 # choose "use existing"
498
+
499
+ monkeypatch.setattr("nabla.ui.select", fake_select)
500
+ kind, value = _inputs_menu("Inputs", existing=existing)
501
+
502
+ assert kind == "file"
503
+ assert value == str(existing)
504
+ assert picked["options"][0][0] == "use existing"
505
+ assert "3 inputs" in picked["options"][0][1]
506
+
507
+
508
+ def test_scan_skips_build_and_dist_dirs(tmp_path, monkeypatch, capsys):
509
+ repo = _make_single_site_repo(tmp_path)
510
+ build_dir = repo / "build" / "lib"
511
+ build_dir.mkdir(parents=True)
512
+ (build_dir / "copy_of_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
513
+
514
+ rc = main(["scan", str(repo)])
515
+
516
+ assert rc == 0
517
+ out = capsys.readouterr().out
518
+ assert "1 call site found" in out
519
+ assert "copy_of_site" not in out
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