nabla-cli 0.6.3__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.
Files changed (36) hide show
  1. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/PKG-INFO +1 -1
  2. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/__init__.py +1 -1
  3. nabla_cli-0.6.4/nabla/branding.py +139 -0
  4. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/cli.py +8 -2
  5. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/recorder.py +8 -0
  6. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/ui.py +53 -44
  7. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/PKG-INFO +1 -1
  8. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/pyproject.toml +1 -1
  9. nabla_cli-0.6.3/nabla/branding.py +0 -101
  10. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/README.md +0 -0
  11. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/__main__.py +0 -0
  12. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/config.py +0 -0
  13. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/contract.py +0 -0
  14. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/induce.py +0 -0
  15. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/profile.py +0 -0
  16. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/prompt_recon.py +0 -0
  17. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/samplegen.py +0 -0
  18. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/scanner.py +0 -0
  19. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/schema_resolver.py +0 -0
  20. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/schemas/site_profile.schema.json +0 -0
  21. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/term.py +0 -0
  22. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/SOURCES.txt +0 -0
  23. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/dependency_links.txt +0 -0
  24. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/entry_points.txt +0 -0
  25. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/requires.txt +0 -0
  26. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/top_level.txt +0 -0
  27. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/setup.cfg +0 -0
  28. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_cli_ux.py +0 -0
  29. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_config.py +0 -0
  30. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_e2e_profile.py +0 -0
  31. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_induce.py +0 -0
  32. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_prompt_recon.py +0 -0
  33. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_recorder.py +0 -0
  34. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_samplegen.py +0 -0
  35. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_scanner.py +0 -0
  36. {nabla_cli-0.6.3 → nabla_cli-0.6.4}/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.3
3
+ Version: 0.6.4
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.3"
3
+ __version__ = "0.6.4"
@@ -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
- print_banner(_out, animate=True)
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(_out, "Provider", options)]
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
 
@@ -167,6 +167,14 @@ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
167
167
  and isinstance(rf, dict) and rf.get("type") == "json_schema"
168
168
  and ("schema" in detail.lower() or "invalid" in detail.lower())):
169
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}
170
178
  fell_back = True
171
179
  print("nabla capture: stand-in oracle rejected this site's json_schema — "
172
180
  "retrying in json_object mode (the profile still carries the real schema)",
@@ -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,46 +276,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
267
276
  console=console,
268
277
  transient=True,
269
278
  )
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
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.6.3
3
+ Version: 0.6.4
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.3"
3
+ version = "0.6.4"
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" }
@@ -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