nabla-cli 0.6.2__tar.gz → 0.6.4__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.6.2 → nabla_cli-0.6.4}/PKG-INFO +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/__init__.py +1 -1
- nabla_cli-0.6.4/nabla/branding.py +139 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/cli.py +60 -48
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/recorder.py +49 -27
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/scanner.py +2 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/ui.py +53 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/PKG-INFO +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/pyproject.toml +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_cli_ux.py +51 -0
- nabla_cli-0.6.2/nabla/branding.py +0 -101
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/README.md +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/__main__.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/config.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/contract.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/induce.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/profile.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/samplegen.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/term.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/SOURCES.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/setup.cfg +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_config.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_induce.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_recorder.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_scanner.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_schema_resolver.py +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
# Uniform thick strokes like the wordmark: solid bar, solid diagonals,
|
|
17
|
+
# single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
|
|
18
|
+
# reads close to the mark's near-equilateral proportions.
|
|
19
|
+
_LOGO = [
|
|
20
|
+
"█▀▀▀▀▀▀▀▀▀█",
|
|
21
|
+
" █ █",
|
|
22
|
+
" █ █",
|
|
23
|
+
" █ █",
|
|
24
|
+
" █ █",
|
|
25
|
+
" █",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_BRAND_STYLE = "bold cyan"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def banner_lines() -> list[tuple[str, str, str]]:
|
|
32
|
+
"""(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
|
|
33
|
+
taglines = [
|
|
34
|
+
("", ""),
|
|
35
|
+
("nabla", "bold"),
|
|
36
|
+
(f"v{__version__} · OpenAI call-site harvester", ""),
|
|
37
|
+
("scan · capture · build", "cyan"),
|
|
38
|
+
("", ""),
|
|
39
|
+
("", ""),
|
|
40
|
+
]
|
|
41
|
+
return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compose() -> Text:
|
|
45
|
+
"""The complete banner, final form."""
|
|
46
|
+
frame = Text()
|
|
47
|
+
for logo_line, tagline, tag_style in banner_lines():
|
|
48
|
+
frame.append(f" {logo_line:<14}", style=_BRAND_STYLE)
|
|
49
|
+
if tagline:
|
|
50
|
+
frame.append(tagline, style=tag_style)
|
|
51
|
+
frame.append("\n")
|
|
52
|
+
return frame
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stroke_frames() -> list[set[tuple[int, int]]]:
|
|
56
|
+
"""Reveal order that traces the mark like a pen: the top bar sweeps
|
|
57
|
+
left to right, then the two diagonals descend together to the apex."""
|
|
58
|
+
frames: list[set[tuple[int, int]]] = []
|
|
59
|
+
mask: set[tuple[int, int]] = set()
|
|
60
|
+
top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
|
|
61
|
+
for i in range(0, len(top_cells), 2):
|
|
62
|
+
mask.update(top_cells[i:i + 2])
|
|
63
|
+
frames.append(set(mask))
|
|
64
|
+
for r in range(1, len(_LOGO)):
|
|
65
|
+
mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
|
|
66
|
+
frames.append(set(mask))
|
|
67
|
+
return frames
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_banner(console: Console | None = None) -> None:
|
|
71
|
+
"""Print the complete banner instantly. Animation, when wanted, happens
|
|
72
|
+
afterwards via animate_in_place() — over this already-rendered region —
|
|
73
|
+
so downstream UI (rails, menus) is on screen before a single frame."""
|
|
74
|
+
console = console or Console()
|
|
75
|
+
if not console.is_terminal:
|
|
76
|
+
return
|
|
77
|
+
console.print()
|
|
78
|
+
console.print(_compose(), end="")
|
|
79
|
+
console.print()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Raw ANSI for the in-place replay: rich Live owns whole-screen regions,
|
|
83
|
+
# but here the cursor must slip above content that is already printed
|
|
84
|
+
# below the banner, so escape codes are driven directly.
|
|
85
|
+
_ANSI = {"brand": "\x1b[1;36m", "bold": "\x1b[1m", "cyan": "\x1b[36m",
|
|
86
|
+
"": "", "reset": "\x1b[0m"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ansi_row(r: int, mask: set[tuple[int, int]]) -> str:
|
|
90
|
+
logo_line, tagline, tag_style = banner_lines()[r]
|
|
91
|
+
revealed = "".join(
|
|
92
|
+
ch if ch == " " or (r, c) in mask else " "
|
|
93
|
+
for c, ch in enumerate(logo_line)
|
|
94
|
+
)
|
|
95
|
+
row = f" {_ANSI['brand']}{revealed:<14}{_ANSI['reset']}"
|
|
96
|
+
if tagline:
|
|
97
|
+
row += f"{_ANSI[tag_style]}{tagline}{_ANSI['reset']}"
|
|
98
|
+
return row + "\x1b[0K" # clear any residue right of the rewritten text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def animate_in_place(console: Console, lines_below: int) -> None:
|
|
102
|
+
"""Replay the logo's stroke-order draw over the banner already on
|
|
103
|
+
screen. ``lines_below`` = rows printed after the banner's trailing
|
|
104
|
+
blank line (rails, menu, ...); the cursor is assumed to sit at the
|
|
105
|
+
start of the row just below them. Everything is rewritten line-for-line
|
|
106
|
+
(taglines included), so the layout cannot shift."""
|
|
107
|
+
if not console.is_terminal:
|
|
108
|
+
return
|
|
109
|
+
import sys as _sys
|
|
110
|
+
import time
|
|
111
|
+
|
|
112
|
+
up = len(_LOGO) + 1 + lines_below # +1: banner's trailing blank line
|
|
113
|
+
write = _sys.stdout.write
|
|
114
|
+
try:
|
|
115
|
+
for mask in _stroke_frames():
|
|
116
|
+
write(f"\x1b[s\x1b[{up}A") # save cursor, jump to logo top
|
|
117
|
+
for r in range(len(_LOGO)):
|
|
118
|
+
write("\r" + _ansi_row(r, mask))
|
|
119
|
+
if r < len(_LOGO) - 1:
|
|
120
|
+
write("\x1b[1B") # down one row
|
|
121
|
+
write("\x1b[u") # restore cursor
|
|
122
|
+
_sys.stdout.flush()
|
|
123
|
+
time.sleep(0.09)
|
|
124
|
+
except Exception: # noqa: BLE001 - cosmetic replay must never break init
|
|
125
|
+
pass
|
|
126
|
+
finally:
|
|
127
|
+
# Terminal state is guaranteed sane: land on the finished mark.
|
|
128
|
+
try:
|
|
129
|
+
full = {(r, c) for r, line in enumerate(_LOGO)
|
|
130
|
+
for c, ch in enumerate(line) if ch != " "}
|
|
131
|
+
write(f"\x1b[s\x1b[{up}A")
|
|
132
|
+
for r in range(len(_LOGO)):
|
|
133
|
+
write("\r" + _ansi_row(r, full))
|
|
134
|
+
if r < len(_LOGO) - 1:
|
|
135
|
+
write("\x1b[1B")
|
|
136
|
+
write("\x1b[u")
|
|
137
|
+
_sys.stdout.flush()
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
pass
|
|
@@ -264,9 +264,12 @@ def _read_api_key(prompt: str) -> str:
|
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def _cmd_init(args) -> int:
|
|
267
|
+
from .branding import animate_in_place
|
|
267
268
|
from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
|
|
268
269
|
|
|
269
|
-
|
|
270
|
+
# Everything renders first — banner (final form), rail, menu — then the
|
|
271
|
+
# logo replays its stroke-order draw in place. Nothing ever shifts.
|
|
272
|
+
print_banner(_out)
|
|
270
273
|
rail_start(_out, "nabla init")
|
|
271
274
|
rail(_out)
|
|
272
275
|
|
|
@@ -274,7 +277,10 @@ def _cmd_init(args) -> int:
|
|
|
274
277
|
if provider is None:
|
|
275
278
|
names = list(PROVIDERS)
|
|
276
279
|
options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
|
|
277
|
-
provider = names[select(
|
|
280
|
+
provider = names[select(
|
|
281
|
+
_out, "Provider", options,
|
|
282
|
+
on_first_draw=lambda menu_lines: animate_in_place(_out, menu_lines + 2),
|
|
283
|
+
)]
|
|
278
284
|
else:
|
|
279
285
|
rail(_out, f"Provider {provider}")
|
|
280
286
|
|
|
@@ -493,19 +499,9 @@ def _cmd_record(args) -> int:
|
|
|
493
499
|
elif args.proxy_cmd:
|
|
494
500
|
source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
|
|
495
501
|
else:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
502
|
+
source, source_kind, err = _resolve_inputs_interactively(args, site)
|
|
503
|
+
if err is not None:
|
|
504
|
+
return err
|
|
509
505
|
|
|
510
506
|
from .ui import make_progress, rail_end, rail_ok
|
|
511
507
|
|
|
@@ -554,46 +550,60 @@ def _cmd_record(args) -> int:
|
|
|
554
550
|
return 0
|
|
555
551
|
|
|
556
552
|
|
|
557
|
-
def _inputs_menu(label: str) -> tuple[str, object]:
|
|
558
|
-
"""The interactive inputs menu
|
|
559
|
-
|
|
553
|
+
def _inputs_menu(label: str, existing: Path | None = None) -> tuple[str, object]:
|
|
554
|
+
"""The interactive inputs menu — shown every capture, so reusing a
|
|
555
|
+
previous inputs file is a choice, not an ambush. Returns ("file", path)
|
|
556
|
+
or ("generate", n)."""
|
|
557
|
+
from .ui import prompt_digits, prompt_text, select
|
|
560
558
|
|
|
561
|
-
options = [
|
|
559
|
+
options = []
|
|
560
|
+
if existing is not None:
|
|
561
|
+
try:
|
|
562
|
+
count = sum(1 for line in existing.read_text(encoding="utf-8").splitlines()
|
|
563
|
+
if line.strip())
|
|
564
|
+
except OSError:
|
|
565
|
+
count = "?"
|
|
566
|
+
options.append(("use existing", f"{existing} · {count} inputs"))
|
|
567
|
+
options += [
|
|
562
568
|
("generate 5", "synthetic inputs — quick look, fewest credits"),
|
|
563
569
|
("generate 10", "synthetic inputs — balanced"),
|
|
564
570
|
("generate 20", "synthetic inputs — handoff floor, most credits"),
|
|
565
571
|
("custom count", "type how many synthetic inputs to generate"),
|
|
566
572
|
("my own file", "type a path to your JSONL of inputs"),
|
|
567
573
|
]
|
|
574
|
+
shift = 1 if existing is not None else 0
|
|
568
575
|
choice = select(_out, label, options)
|
|
569
|
-
if choice ==
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
_warn("enter a positive number")
|
|
575
|
-
if choice == 4:
|
|
576
|
+
if existing is not None and choice == 0:
|
|
577
|
+
return "file", str(existing)
|
|
578
|
+
if choice == shift + 3:
|
|
579
|
+
return "generate", prompt_digits(_out, "How many", default=5)
|
|
580
|
+
if choice == shift + 4:
|
|
576
581
|
while True:
|
|
577
582
|
typed = prompt_text(_out, "Path to JSONL")
|
|
578
583
|
if typed and Path(typed).exists():
|
|
579
584
|
return "file", typed
|
|
580
585
|
_warn(f"{typed or '(empty)'} not found — try again, or Ctrl+C to abort")
|
|
581
|
-
return "generate", (5, 10, 20)[choice]
|
|
586
|
+
return "generate", (5, 10, 20)[choice - shift]
|
|
582
587
|
|
|
583
588
|
|
|
584
589
|
def _resolve_inputs_interactively(args, site):
|
|
585
|
-
"""No --inputs given
|
|
586
|
-
|
|
587
|
-
generate the default quietly. Returns
|
|
588
|
-
with exit_code None on success."""
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
590
|
+
"""No --inputs given. Interactively, ask where inputs come from (existing
|
|
591
|
+
file / generate N / custom count / own file); otherwise use an existing
|
|
592
|
+
conventional file or generate the default quietly. Returns
|
|
593
|
+
(source, source_kind, exit_code) with exit_code None on success."""
|
|
594
|
+
conventional = _find_conventional_inputs(site)
|
|
595
|
+
|
|
596
|
+
if args.num_samples is None and _out.is_terminal and sys.stdin.isatty():
|
|
597
|
+
kind, value = _inputs_menu("Inputs", existing=conventional)
|
|
592
598
|
if kind == "file":
|
|
593
599
|
return RecordSource(kind="samples", samples_file=value), None, None
|
|
594
600
|
n = value
|
|
595
|
-
elif
|
|
596
|
-
|
|
601
|
+
elif conventional is not None and args.num_samples is None:
|
|
602
|
+
from .ui import rail
|
|
603
|
+
rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
|
|
604
|
+
return RecordSource(kind="samples", samples_file=str(conventional)), None, None
|
|
605
|
+
else:
|
|
606
|
+
n = args.num_samples or 5
|
|
597
607
|
|
|
598
608
|
source, err = _generate_samples_source(args, site, Path(f"{site.symbol}.inputs.jsonl"), n)
|
|
599
609
|
if err is not None:
|
|
@@ -637,25 +647,27 @@ def _capture_many(args, supported) -> int:
|
|
|
637
647
|
rail(_out, f"capturing all {len(targets)} supported call sites")
|
|
638
648
|
|
|
639
649
|
# Phase 1 — decide inputs per site, sequentially (menus can't overlap).
|
|
650
|
+
# The menu is shown every time; an existing inputs file is offered as
|
|
651
|
+
# the default option, never silently assumed.
|
|
640
652
|
plans: list[dict] = []
|
|
641
653
|
for site in targets:
|
|
642
654
|
conventional = _find_conventional_inputs(site)
|
|
643
|
-
if
|
|
655
|
+
if args.num_samples is None and interactive:
|
|
656
|
+
kind, value = _inputs_menu(f"Inputs · {site.symbol}", existing=conventional)
|
|
657
|
+
if kind == "file":
|
|
658
|
+
plans.append({"site": site, "kind": "file", "path": value,
|
|
659
|
+
"source_kind": "samples"})
|
|
660
|
+
else:
|
|
661
|
+
plans.append({"site": site, "kind": "generate", "n": value,
|
|
662
|
+
"source_kind": "generated_samples"})
|
|
663
|
+
continue
|
|
664
|
+
if conventional is not None and args.num_samples is None:
|
|
644
665
|
rail(_out, f"{site.symbol}: using {conventional}")
|
|
645
666
|
plans.append({"site": site, "kind": "file", "path": str(conventional),
|
|
646
667
|
"source_kind": "samples"})
|
|
647
668
|
continue
|
|
648
|
-
|
|
649
|
-
|
|
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"})
|
|
669
|
+
plans.append({"site": site, "kind": "generate",
|
|
670
|
+
"n": args.num_samples or 5, "source_kind": "generated_samples"})
|
|
659
671
|
|
|
660
672
|
# Phase 2 — run all captures in parallel.
|
|
661
673
|
progress = make_progress(_out) if _out.is_terminal else None
|
|
@@ -131,42 +131,64 @@ 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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
# Providers require the word "json" in the messages to use
|
|
171
|
+
# json_object mode. This nudge goes only to the stand-in —
|
|
172
|
+
# the proxy captured the original request before forwarding,
|
|
173
|
+
# so the recorded prompt is untouched.
|
|
174
|
+
messages = list(body.get("messages", []))
|
|
175
|
+
if "json" not in json.dumps(messages).lower():
|
|
176
|
+
messages.append({"role": "system", "content": "Respond in JSON."})
|
|
177
|
+
body = {**body, "messages": messages}
|
|
178
|
+
fell_back = True
|
|
179
|
+
print("nabla capture: stand-in oracle rejected this site's json_schema — "
|
|
180
|
+
"retrying in json_object mode (the profile still carries the real schema)",
|
|
181
|
+
file=sys.stderr)
|
|
182
|
+
continue
|
|
183
|
+
if attempt >= len(delays) or exc.code not in (429, 500, 502, 503):
|
|
162
184
|
raise RuntimeError(f"upstream HTTP {exc.code}: {detail or exc.reason}") from exc
|
|
163
185
|
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
164
186
|
try:
|
|
165
|
-
wait = max(float(retry_after),
|
|
187
|
+
wait = max(float(retry_after), delays[attempt]) if retry_after else delays[attempt]
|
|
166
188
|
except ValueError:
|
|
167
|
-
wait =
|
|
189
|
+
wait = delays[attempt]
|
|
168
190
|
time.sleep(min(wait, 60))
|
|
169
|
-
|
|
191
|
+
attempt += 1
|
|
170
192
|
|
|
171
193
|
|
|
172
194
|
def _default_responder() -> Responder:
|
|
@@ -415,10 +437,10 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
|
|
|
415
437
|
timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
|
|
416
438
|
proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
|
|
417
439
|
if proc.returncode != 0:
|
|
418
|
-
# The last stderr
|
|
419
|
-
# traceback scaffolding that buries
|
|
440
|
+
# The last stderr line carries the actual exception; everything above
|
|
441
|
+
# it is traceback scaffolding that buries the signal.
|
|
420
442
|
lines = [l for l in (proc.stderr or proc.stdout or "").splitlines() if l.strip()]
|
|
421
|
-
detail =
|
|
443
|
+
detail = lines[-1].strip() if lines else "no output"
|
|
422
444
|
raise RuntimeError(
|
|
423
445
|
f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
|
|
424
446
|
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
|
|
|
@@ -67,9 +67,13 @@ def rail_end(console: Console, text: str, style: str = "cyan") -> None:
|
|
|
67
67
|
# ---------------------------------------------------------------------------
|
|
68
68
|
|
|
69
69
|
def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
70
|
-
default: int = 0) -> int:
|
|
70
|
+
default: int = 0, on_first_draw=None) -> int:
|
|
71
71
|
"""Pick one of ``options`` ([(name, description), ...]); returns the index.
|
|
72
72
|
|
|
73
|
+
``on_first_draw(lines_drawn)`` fires once after the menu is fully on
|
|
74
|
+
screen, before the first key is read — the hook that lets a banner
|
|
75
|
+
animation replay above an already-complete layout.
|
|
76
|
+
|
|
73
77
|
Interactive Windows console: arrow keys + enter, pointer rendering,
|
|
74
78
|
menu collapses to a single confirmation line after the choice.
|
|
75
79
|
Anywhere else: numbered prompt.
|
|
@@ -123,6 +127,11 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
|
123
127
|
lines_drawn = len(options) + 1
|
|
124
128
|
|
|
125
129
|
draw()
|
|
130
|
+
if on_first_draw is not None:
|
|
131
|
+
try:
|
|
132
|
+
on_first_draw(lines_drawn)
|
|
133
|
+
except Exception: # noqa: BLE001 - a cosmetic hook must never break input
|
|
134
|
+
pass
|
|
126
135
|
while True:
|
|
127
136
|
ch = msvcrt.getwch()
|
|
128
137
|
if ch in ("\r", "\n"):
|
|
@@ -267,3 +276,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
|
|
|
267
276
|
console=console,
|
|
268
277
|
transient=True,
|
|
269
278
|
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def prompt_digits(console: Console, label: str, default: int) -> int:
|
|
282
|
+
"""Numeric input that cannot go wrong: on an interactive Windows console
|
|
283
|
+
it reads keys raw — digits echo, backspace works, everything else
|
|
284
|
+
(arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
|
|
285
|
+
it strips non-digits from a plain input()."""
|
|
286
|
+
prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
|
|
287
|
+
prompt.append(label, style="bold")
|
|
288
|
+
prompt.append(f" [{default}]", style="cyan")
|
|
289
|
+
prompt.append(" ")
|
|
290
|
+
|
|
291
|
+
interactive = (
|
|
292
|
+
sys.platform == "win32"
|
|
293
|
+
and console.is_terminal
|
|
294
|
+
and sys.stdin.isatty()
|
|
295
|
+
)
|
|
296
|
+
if not interactive:
|
|
297
|
+
console.print(prompt, end="")
|
|
298
|
+
raw = "".join(ch for ch in input() if ch.isdigit())
|
|
299
|
+
return int(raw) if raw else default
|
|
300
|
+
|
|
301
|
+
import msvcrt
|
|
302
|
+
|
|
303
|
+
console.print(prompt, end="")
|
|
304
|
+
chars: list[str] = []
|
|
305
|
+
while True:
|
|
306
|
+
ch = msvcrt.getwch()
|
|
307
|
+
if ch in ("\r", "\n"):
|
|
308
|
+
print()
|
|
309
|
+
break
|
|
310
|
+
if ch == "\x03":
|
|
311
|
+
raise KeyboardInterrupt
|
|
312
|
+
if ch == "\b":
|
|
313
|
+
if chars:
|
|
314
|
+
chars.pop()
|
|
315
|
+
print("\b \b", end="", flush=True)
|
|
316
|
+
elif ch.isdigit():
|
|
317
|
+
chars.append(ch)
|
|
318
|
+
print(ch, end="", flush=True)
|
|
319
|
+
elif ch in ("\xe0", "\x00"):
|
|
320
|
+
msvcrt.getwch() # swallow the arrow/function-key payload
|
|
321
|
+
return int("".join(chars)) if chars else default
|
|
@@ -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
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
# Uniform thick strokes like the wordmark: solid bar, solid diagonals,
|
|
17
|
-
# single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
|
|
18
|
-
# reads close to the mark's near-equilateral proportions.
|
|
19
|
-
_LOGO = [
|
|
20
|
-
"█▀▀▀▀▀▀▀▀▀█",
|
|
21
|
-
" █ █",
|
|
22
|
-
" █ █",
|
|
23
|
-
" █ █",
|
|
24
|
-
" █ █",
|
|
25
|
-
" █",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
_BRAND_STYLE = "bold cyan"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def banner_lines() -> list[tuple[str, str, str]]:
|
|
32
|
-
"""(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
|
|
33
|
-
taglines = [
|
|
34
|
-
("", ""),
|
|
35
|
-
("nabla", "bold"),
|
|
36
|
-
(f"v{__version__} · OpenAI call-site harvester", ""),
|
|
37
|
-
("scan · capture · build", "cyan"),
|
|
38
|
-
("", ""),
|
|
39
|
-
("", ""),
|
|
40
|
-
]
|
|
41
|
-
return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _compose(traced: set[tuple[int, int]] | None = None) -> Text:
|
|
45
|
-
"""The full banner. With ``traced`` given, every glyph is still present
|
|
46
|
-
(no layout shift, ever) — untraced stroke cells just render quieter, and
|
|
47
|
-
the traced set brightens as the animation sweeps through."""
|
|
48
|
-
frame = Text()
|
|
49
|
-
for r, (logo_line, tagline, tag_style) in enumerate(banner_lines()):
|
|
50
|
-
frame.append(" ")
|
|
51
|
-
for c, ch in enumerate(f"{logo_line:<14}"):
|
|
52
|
-
if ch == " ":
|
|
53
|
-
frame.append(" ")
|
|
54
|
-
elif traced is None or (r, c) in traced:
|
|
55
|
-
frame.append(ch, style=_BRAND_STYLE)
|
|
56
|
-
else:
|
|
57
|
-
frame.append(ch, style="dim cyan")
|
|
58
|
-
if tagline:
|
|
59
|
-
frame.append(tagline, style=tag_style)
|
|
60
|
-
frame.append("\n")
|
|
61
|
-
return frame
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _stroke_frames() -> list[set[tuple[int, int]]]:
|
|
65
|
-
"""Reveal order that traces the mark like a pen: the top bar sweeps
|
|
66
|
-
left to right, then the two diagonals descend together to the apex."""
|
|
67
|
-
frames: list[set[tuple[int, int]]] = []
|
|
68
|
-
mask: set[tuple[int, int]] = set()
|
|
69
|
-
top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
|
|
70
|
-
for i in range(0, len(top_cells), 2):
|
|
71
|
-
mask.update(top_cells[i:i + 2])
|
|
72
|
-
frames.append(set(mask))
|
|
73
|
-
for r in range(1, len(_LOGO)):
|
|
74
|
-
mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
|
|
75
|
-
frames.append(set(mask))
|
|
76
|
-
return frames
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def print_banner(console: Console | None = None, animate: bool = False) -> None:
|
|
80
|
-
console = console or Console()
|
|
81
|
-
if not console.is_terminal:
|
|
82
|
-
return
|
|
83
|
-
console.print()
|
|
84
|
-
if animate:
|
|
85
|
-
import time
|
|
86
|
-
|
|
87
|
-
from rich.live import Live
|
|
88
|
-
|
|
89
|
-
# Full-size first frame: the complete banner is on screen before a
|
|
90
|
-
# single animation tick, so the layout never shifts — the sweep only
|
|
91
|
-
# changes brightness along the stroke.
|
|
92
|
-
with Live(_compose(traced=set()), console=console,
|
|
93
|
-
refresh_per_second=30, transient=False) as live:
|
|
94
|
-
time.sleep(0.12)
|
|
95
|
-
for traced in _stroke_frames():
|
|
96
|
-
live.update(_compose(traced=traced))
|
|
97
|
-
time.sleep(0.09)
|
|
98
|
-
live.update(_compose())
|
|
99
|
-
else:
|
|
100
|
-
console.print(_compose(), end="")
|
|
101
|
-
console.print()
|
|
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
|