nabla-cli 0.2.0__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.0 → nabla_cli-0.3.0}/PKG-INFO +2 -1
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/__init__.py +1 -1
- nabla_cli-0.3.0/nabla/branding.py +50 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/cli.py +141 -29
- nabla_cli-0.3.0/nabla/term.py +53 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/PKG-INFO +2 -1
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/SOURCES.txt +2 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/requires.txt +1 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/pyproject.toml +2 -1
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_cli_ux.py +20 -2
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/README.md +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/config.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/contract.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/induce.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/profile.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/recorder.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/samplegen.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/setup.cfg +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_config.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_scanner.py +0 -0
- {nabla_cli-0.2.0 → 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,29 @@ 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)
|
|
34
|
+
|
|
19
35
|
_PROVIDER_DESCRIPTIONS = {
|
|
20
36
|
"openai": "real OpenAI API, uses your OPENAI_API_KEY",
|
|
21
37
|
"groq": "free tier, openai/gpt-oss-120b",
|
|
@@ -36,6 +52,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
36
52
|
" nabla profile write the site profile\n"
|
|
37
53
|
),
|
|
38
54
|
)
|
|
55
|
+
parser.add_argument("--version", action="version", version=f"nabla {__version__}")
|
|
39
56
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
57
|
|
|
41
58
|
p_init = sub.add_parser(
|
|
@@ -114,6 +131,34 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
114
131
|
return parser
|
|
115
132
|
|
|
116
133
|
|
|
134
|
+
def _ok(msg: str) -> None:
|
|
135
|
+
_out.print(f"✓ {msg}" if _out.is_terminal else msg, style="green", markup=False)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _warn(msg: str) -> None:
|
|
139
|
+
_errcon.print(msg, style="yellow", markup=False)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _fail(msg: str) -> None:
|
|
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)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _unconfigured() -> bool:
|
|
151
|
+
"""True when no provider is set up anywhere — the state where record/
|
|
152
|
+
profile will fail and the user should be nudged toward `nabla init`."""
|
|
153
|
+
try:
|
|
154
|
+
provider = apply_to_env()
|
|
155
|
+
except Exception:
|
|
156
|
+
provider = None
|
|
157
|
+
return provider is None and not (
|
|
158
|
+
os.environ.get("OPENAI_API_KEY") or os.environ.get("NABLA_UPSTREAM_API_KEY")
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
117
162
|
def main(argv: list[str] | None = None) -> int:
|
|
118
163
|
# LLM-produced text (goal descriptions, error bodies) can contain
|
|
119
164
|
# characters legacy Windows consoles (cp1252) can't encode; degrade to
|
|
@@ -132,6 +177,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
132
177
|
parser = _build_parser()
|
|
133
178
|
argv = sys.argv[1:] if argv is None else argv
|
|
134
179
|
if not argv:
|
|
180
|
+
print_banner(_out)
|
|
135
181
|
parser.print_help()
|
|
136
182
|
return 0
|
|
137
183
|
|
|
@@ -145,13 +191,43 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
145
191
|
return commands[args.command](args)
|
|
146
192
|
|
|
147
193
|
|
|
194
|
+
def _read_api_key(prompt: str) -> str:
|
|
195
|
+
"""Read a secret with visible masking: one `*` per character, backspace
|
|
196
|
+
supported, pasting shows a `*` per pasted character — so the user can
|
|
197
|
+
see that their input (typed or pasted) actually arrived. Falls back to
|
|
198
|
+
fully hidden input where character-level reading isn't available."""
|
|
199
|
+
if sys.platform == "win32" and sys.stdin.isatty():
|
|
200
|
+
import msvcrt
|
|
201
|
+
|
|
202
|
+
print(prompt, end="", flush=True)
|
|
203
|
+
chars: list[str] = []
|
|
204
|
+
while True:
|
|
205
|
+
ch = msvcrt.getwch()
|
|
206
|
+
if ch in ("\r", "\n"):
|
|
207
|
+
print()
|
|
208
|
+
break
|
|
209
|
+
if ch == "\x03": # Ctrl+C
|
|
210
|
+
raise KeyboardInterrupt
|
|
211
|
+
if ch == "\b":
|
|
212
|
+
if chars:
|
|
213
|
+
chars.pop()
|
|
214
|
+
print("\b \b", end="", flush=True)
|
|
215
|
+
elif ch >= " ":
|
|
216
|
+
chars.append(ch)
|
|
217
|
+
print("*", end="", flush=True)
|
|
218
|
+
return "".join(chars).strip()
|
|
219
|
+
return getpass(f"{prompt} (input is hidden — paste and press Enter): ").strip()
|
|
220
|
+
|
|
221
|
+
|
|
148
222
|
def _cmd_init(args) -> int:
|
|
223
|
+
print_banner(_out)
|
|
149
224
|
provider = args.provider
|
|
150
225
|
if provider is None:
|
|
151
226
|
names = list(PROVIDERS)
|
|
152
|
-
print("Pick a provider:")
|
|
227
|
+
_out.print("Pick a provider:", style="bold")
|
|
153
228
|
for i, name in enumerate(names, start=1):
|
|
154
|
-
print(f" {i}) {name} —
|
|
229
|
+
_out.print(f" [cyan]{i})[/cyan] [bold]{name}[/bold] — "
|
|
230
|
+
f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
|
|
155
231
|
choice = input("> ").strip()
|
|
156
232
|
if choice.isdigit() and 1 <= int(choice) <= len(names):
|
|
157
233
|
provider = names[int(choice) - 1]
|
|
@@ -161,25 +237,25 @@ def _cmd_init(args) -> int:
|
|
|
161
237
|
api_key = args.api_key
|
|
162
238
|
if api_key is None:
|
|
163
239
|
key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
|
|
164
|
-
api_key =
|
|
240
|
+
api_key = _read_api_key(f"Paste your {key_env}: ")
|
|
165
241
|
|
|
166
242
|
if not api_key:
|
|
167
|
-
|
|
243
|
+
_fail("nabla init: no API key given")
|
|
168
244
|
return 2
|
|
169
245
|
|
|
170
246
|
try:
|
|
171
247
|
path = save_config(provider, api_key)
|
|
172
248
|
except ValueError as exc:
|
|
173
|
-
|
|
249
|
+
_fail(f"nabla init: {exc}")
|
|
174
250
|
return 2
|
|
175
251
|
|
|
176
252
|
masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
|
|
177
|
-
|
|
253
|
+
_ok(f"nabla init: saved to {path} (key: {masked})")
|
|
178
254
|
try:
|
|
179
255
|
_ensure_scripts_on_path()
|
|
180
256
|
except Exception:
|
|
181
257
|
pass # PATH help is best-effort; setup itself already succeeded
|
|
182
|
-
|
|
258
|
+
_next_step("nabla scan (run it from inside the repo you want to harvest)")
|
|
183
259
|
return 0
|
|
184
260
|
|
|
185
261
|
|
|
@@ -245,11 +321,42 @@ def _cmd_scan(args) -> int:
|
|
|
245
321
|
if args.json:
|
|
246
322
|
print(json.dumps(rows, indent=2))
|
|
247
323
|
return 0
|
|
248
|
-
|
|
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")
|
|
332
|
+
supported_count = 0
|
|
249
333
|
for r in rows:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
334
|
+
verif = r["verifiability"]
|
|
335
|
+
harvestable = is_supported(Verifiability(verif), r["flags"])
|
|
336
|
+
if harvestable:
|
|
337
|
+
supported_count += 1
|
|
338
|
+
if verif in ("json_schema", "pydantic_parse"):
|
|
339
|
+
verif_color = "green"
|
|
340
|
+
elif verif in ("tool_call", "json_object_no_schema"):
|
|
341
|
+
verif_color = "yellow"
|
|
342
|
+
else:
|
|
343
|
+
verif_color = "dim"
|
|
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 ""))
|
|
357
|
+
if _unconfigured():
|
|
358
|
+
_warn("tip: run `nabla init` once to set up a provider — `nabla record` "
|
|
359
|
+
"and `nabla profile` need one")
|
|
253
360
|
return 0
|
|
254
361
|
|
|
255
362
|
|
|
@@ -282,7 +389,8 @@ def _resolve_site(args, sites):
|
|
|
282
389
|
|
|
283
390
|
if len(supported) == 1:
|
|
284
391
|
site = supported[0]
|
|
285
|
-
print(f"nabla: using site {site.symbol} (the only supported call site)"
|
|
392
|
+
_out.print(f"nabla: using site {site.symbol} (the only supported call site)",
|
|
393
|
+
style="dim", markup=False)
|
|
286
394
|
return site, None
|
|
287
395
|
|
|
288
396
|
candidates = supported or sites
|
|
@@ -307,14 +415,11 @@ def _cmd_record(args) -> int:
|
|
|
307
415
|
if err is not None:
|
|
308
416
|
return err
|
|
309
417
|
|
|
310
|
-
if
|
|
311
|
-
|
|
312
|
-
):
|
|
313
|
-
print(
|
|
418
|
+
if _unconfigured():
|
|
419
|
+
_warn(
|
|
314
420
|
"nabla record: warning — no API key configured (OPENAI_API_KEY / "
|
|
315
421
|
"NABLA_UPSTREAM_API_KEY unset, and no saved config). Run `nabla init` "
|
|
316
|
-
"first unless this call site never reaches a real upstream."
|
|
317
|
-
file=sys.stderr,
|
|
422
|
+
"first unless this call site never reaches a real upstream."
|
|
318
423
|
)
|
|
319
424
|
|
|
320
425
|
source_kind = None # written to the JSONL; may differ from source.kind
|
|
@@ -338,17 +443,21 @@ def _cmd_record(args) -> int:
|
|
|
338
443
|
try:
|
|
339
444
|
examples = record(site, source, args.repo)
|
|
340
445
|
except ZeroTrafficError as exc:
|
|
341
|
-
|
|
446
|
+
_fail(f"nabla record: {exc}")
|
|
342
447
|
return 1
|
|
343
448
|
out = args.out or f"{site.symbol}.recorded.jsonl"
|
|
344
449
|
with open(out, "w", encoding="utf-8") as f:
|
|
345
450
|
for ex in examples:
|
|
346
451
|
f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
|
|
347
|
-
|
|
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")
|
|
348
457
|
if args.out is None and args.site is None:
|
|
349
|
-
|
|
458
|
+
_next_step(f"nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
|
|
350
459
|
else:
|
|
351
|
-
|
|
460
|
+
_next_step(f"nabla profile {args.repo} --site {site.symbol} --examples {out}")
|
|
352
461
|
return 0
|
|
353
462
|
|
|
354
463
|
|
|
@@ -425,13 +534,16 @@ def _cmd_profile(args) -> int:
|
|
|
425
534
|
out = args.out or f"{site.symbol}.profile.json"
|
|
426
535
|
Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
|
427
536
|
supported = profile["classification"]["supported"]
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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)
|
|
431
544
|
if len(examples) < 20:
|
|
432
|
-
|
|
433
|
-
"
|
|
434
|
-
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")
|
|
435
547
|
return 0
|
|
436
548
|
|
|
437
549
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Minimal terminal styling — ANSI colors with zero dependencies.
|
|
2
|
+
|
|
3
|
+
Colors are applied only when the target stream is a real terminal and
|
|
4
|
+
NO_COLOR is unset, so piped/captured output stays plain (tests, --json
|
|
5
|
+
consumers, CI logs). init_terminal() switches Windows consoles into VT
|
|
6
|
+
mode, which cmd.exe does not enable by default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
_CODES = {
|
|
15
|
+
"bold": "1",
|
|
16
|
+
"dim": "2",
|
|
17
|
+
"red": "31",
|
|
18
|
+
"green": "32",
|
|
19
|
+
"yellow": "33",
|
|
20
|
+
"cyan": "36",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init_terminal() -> None:
|
|
25
|
+
if sys.platform != "win32":
|
|
26
|
+
return
|
|
27
|
+
try:
|
|
28
|
+
import ctypes
|
|
29
|
+
|
|
30
|
+
kernel32 = ctypes.windll.kernel32
|
|
31
|
+
for handle_id in (-11, -12): # stdout, stderr
|
|
32
|
+
handle = kernel32.GetStdHandle(handle_id)
|
|
33
|
+
mode = ctypes.c_ulong()
|
|
34
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
35
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
36
|
+
except Exception:
|
|
37
|
+
pass # styling is cosmetic; never let it break a command
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def supports_color(stream=None) -> bool:
|
|
41
|
+
stream = stream or sys.stdout
|
|
42
|
+
if os.environ.get("NO_COLOR"):
|
|
43
|
+
return False
|
|
44
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def style(text: str, *names: str, stream=None) -> str:
|
|
48
|
+
"""Wrap text in ANSI codes when the stream supports it. Apply AFTER any
|
|
49
|
+
column padding — escape codes are invisible but count toward len()."""
|
|
50
|
+
if not names or not supports_color(stream):
|
|
51
|
+
return text
|
|
52
|
+
codes = ";".join(_CODES[n] for n in names)
|
|
53
|
+
return f"\x1b[{codes}m{text}\x1b[0m"
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -12,6 +13,7 @@ nabla/recorder.py
|
|
|
12
13
|
nabla/samplegen.py
|
|
13
14
|
nabla/scanner.py
|
|
14
15
|
nabla/schema_resolver.py
|
|
16
|
+
nabla/term.py
|
|
15
17
|
nabla/schemas/site_profile.schema.json
|
|
16
18
|
nabla_cli.egg-info/PKG-INFO
|
|
17
19
|
nabla_cli.egg-info/SOURCES.txt
|
|
@@ -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):
|
|
@@ -330,3 +330,21 @@ def test_ensure_scripts_on_path_non_windows_prints_instruction(monkeypatch, caps
|
|
|
330
330
|
out = capsys.readouterr().out
|
|
331
331
|
assert "PATH" in out
|
|
332
332
|
assert "python -m nabla" in out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_scan_prints_init_tip_when_unconfigured(capsys):
|
|
336
|
+
rc = main(["scan", FIXTURE_REPO])
|
|
337
|
+
assert rc == 0
|
|
338
|
+
assert "nabla init" in capsys.readouterr().err
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_scan_json_output_stays_clean_json(capsys):
|
|
342
|
+
rc = main(["scan", FIXTURE_REPO, "--json"])
|
|
343
|
+
assert rc == 0
|
|
344
|
+
json.loads(capsys.readouterr().out) # must parse — no tip/color on stdout
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_style_is_plain_when_not_a_tty(capsys):
|
|
348
|
+
from nabla.term import style
|
|
349
|
+
|
|
350
|
+
assert style("hello", "red") == "hello" # captured stdout is not a tty
|
|
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
|