nabla-cli 0.9.0__tar.gz → 0.9.2__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 (39) hide show
  1. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/PKG-INFO +1 -1
  2. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/__init__.py +1 -1
  3. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/cli.py +17 -2
  4. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/puller.py +148 -119
  5. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/pusher.py +8 -1
  6. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla_cli.egg-info/PKG-INFO +1 -1
  7. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/pyproject.toml +1 -1
  8. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_cli_ux.py +3 -1
  9. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_pull.py +79 -76
  10. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_push.py +3 -1
  11. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/README.md +0 -0
  12. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/__main__.py +0 -0
  13. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/branding.py +0 -0
  14. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/config.py +0 -0
  15. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/contract.py +0 -0
  16. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/induce.py +0 -0
  17. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/profile.py +0 -0
  18. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/prompt_recon.py +0 -0
  19. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/recorder.py +0 -0
  20. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/samplegen.py +0 -0
  21. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/scanner.py +0 -0
  22. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/schema_resolver.py +0 -0
  23. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/schemas/site_profile.schema.json +0 -0
  24. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/term.py +0 -0
  25. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla/ui.py +0 -0
  26. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla_cli.egg-info/SOURCES.txt +0 -0
  27. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla_cli.egg-info/dependency_links.txt +0 -0
  28. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla_cli.egg-info/entry_points.txt +0 -0
  29. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla_cli.egg-info/requires.txt +0 -0
  30. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/nabla_cli.egg-info/top_level.txt +0 -0
  31. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/setup.cfg +0 -0
  32. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_config.py +0 -0
  33. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_e2e_profile.py +0 -0
  34. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_induce.py +0 -0
  35. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_prompt_recon.py +0 -0
  36. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_recorder.py +0 -0
  37. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_samplegen.py +0 -0
  38. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/tests/test_scanner.py +0 -0
  39. {nabla_cli-0.9.0 → nabla_cli-0.9.2}/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.9.0
3
+ Version: 0.9.2
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.9.0"
3
+ __version__ = "0.9.2"
@@ -14,7 +14,7 @@ from rich.console import Console
14
14
  from . import __version__
15
15
  from .branding import print_banner
16
16
  from .config import PROVIDERS, apply_to_env, save_config
17
- from .contract import Example, Verifiability, is_supported
17
+ from .contract import Example, Verifiability, compute_site_id, is_supported
18
18
  from .profile import build_profile, find_site
19
19
  from .recorder import RecordSource, ZeroTrafficError, record
20
20
  from .scanner import scan
@@ -947,6 +947,11 @@ def _build_one(args, site, final_rail: bool = False, mode: str = "profile") -> i
947
947
 
948
948
  if mode == "jeremy":
949
949
  seed = {
950
+ # Stable site identity (template+schema hash) — the thread that
951
+ # lets `pull` later match a fine-tuned deployment back to this
952
+ # exact call site. Same value as the profile's site_id.
953
+ "site_id": compute_site_id(template.template, schema),
954
+ "symbol": site.symbol,
950
955
  "goal": _compose_seed_goal(goal),
951
956
  "system_prompt": template.system_prompt or "",
952
957
  "examples": [
@@ -1083,6 +1088,12 @@ def _cmd_pull(args) -> int:
1083
1088
  for site in supported:
1084
1089
  replaces = extract_sampling(site, args.repo).get("model") or "unknown"
1085
1090
  deployments.append((site, mock_deployment(site.symbol, replaces)))
1091
+ # Simulation richness: a lone site still demos a multi-row pull.
1092
+ filler = None
1093
+ if len(deployments) == 1 and args.site is None:
1094
+ from .puller import filler_deployment
1095
+ filler = filler_deployment()
1096
+ deployments.append(filler)
1086
1097
 
1087
1098
  rail(_out)
1088
1099
  table = Table(box=rich_box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
@@ -1098,10 +1109,14 @@ def _cmd_pull(args) -> int:
1098
1109
  )
1099
1110
  _out.print(table)
1100
1111
 
1112
+ from .puller import filler_diff
1113
+
1101
1114
  for site, dep in deployments:
1102
1115
  rail(_out)
1103
1116
  rail(_out, f"{site.symbol} · {site.file}", style="bold")
1104
- for style, line in build_diff(args.repo, site, dep):
1117
+ is_filler = filler is not None and site is filler[0]
1118
+ diff = filler_diff(dep) if is_filler else build_diff(args.repo, site, dep)
1119
+ for style, line in diff:
1105
1120
  color = {"add": "green", "del": "red", "hunk": "cyan",
1106
1121
  "head": "bold", "ctx": ""}[style]
1107
1122
  rail(_out, line, style=color)
@@ -1,119 +1,148 @@
1
- """`nabla pull` — UX simulation of the loop-closing command (cli-plan §7).
2
-
3
- The deployments API doesn't exist yet, so this module renders the full
4
- intended experience — deployment table, per-site diffs, apply + verify,
5
- before/after report — as a faithful simulation that NEVER writes anything:
6
- no file edits, no .env changes, no network calls. Diffs are built from the
7
- target repo's real source lines so the preview is true to life; deployment
8
- rows are fabricated deterministically from the site symbol.
9
-
10
- When the real backend lands, the fabrication points (_mock_deployments,
11
- the apply/verify steps) get replaced by API calls and real edits; the
12
- rendering stays.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import os
18
- import time
19
- from pathlib import Path
20
-
21
- from .config import load_config
22
-
23
- DEFAULT_ENDPOINT = "http://127.0.0.1:8000/deployments"
24
- _BASE_URL = "https://api.freesolo.ai/v1"
25
- _KEY_ENV = "NABLA_MODEL_API_KEY"
26
-
27
-
28
- def resolve_endpoint(flag_value: str | None = None) -> str:
29
- if flag_value:
30
- return flag_value
31
- env = os.environ.get("NABLA_PULL_ENDPOINT")
32
- if env:
33
- return env
34
- stored = load_config().get("pull_endpoint")
35
- if stored:
36
- return stored
37
- return DEFAULT_ENDPOINT
38
-
39
-
40
- def mock_deployment(symbol: str, replaces: str) -> dict:
41
- """A deterministic, plausible deployment row for one site."""
42
- slug = symbol.replace("_", "-")
43
- seed = sum(ord(c) for c in symbol)
44
- return {
45
- "symbol": symbol,
46
- "status": "ready",
47
- "model": f"freesolo/qwen3.5-0.8b-ft-{slug}",
48
- "base_url": _BASE_URL,
49
- "api_key_env": _KEY_ENV,
50
- "replaces": replaces or "unknown",
51
- "pass_rate": round(0.88 + (seed % 10) / 100, 2),
52
- "schema_valid_rate": 1.0,
53
- }
54
-
55
-
56
- def build_diff(repo: str, site, deployment: dict) -> list[tuple[str, str]]:
57
- """(style, line) pairs for a unified diff of the intended rewrite,
58
- grounded in the real source. Read-only."""
59
- path = Path(repo) / site.file
60
- try:
61
- lines = path.read_text(encoding="utf-8").splitlines()
62
- except OSError:
63
- return []
64
-
65
- env_suffix = site.symbol.upper()
66
- out: list[tuple[str, str]] = [
67
- ("head", f"--- a/{site.file}"),
68
- ("head", f"+++ b/{site.file}"),
69
- ]
70
-
71
- # Hunk 1: client construction, if a literal OpenAI(...) line exists.
72
- client_idx = next((i for i, l in enumerate(lines)
73
- if "OpenAI(" in l and "import" not in l), None)
74
- if client_idx is not None:
75
- old = lines[client_idx]
76
- indent = old[: len(old) - len(old.lstrip())]
77
- assign = old.split("OpenAI(", 1)[0]
78
- out += [
79
- ("hunk", f"@@ -{client_idx + 1},1 +{client_idx + 1},2 @@"),
80
- ("del", f"-{old}"),
81
- ("add", f"+{assign}OpenAI("),
82
- ("add", f"+{indent} base_url=os.environ.get(\"NABLA_BASE_URL\", "
83
- f"\"{deployment['base_url']}\"),"),
84
- ("add", f"+{indent} api_key=os.environ[\"{deployment['api_key_env']}\"],"),
85
- ("add", f"+{indent})"),
86
- ]
87
-
88
- # Hunk 2: the model kwarg inside the call span.
89
- start, end = site.span
90
- model_idx = next((i for i in range(start - 1, min(end, len(lines)))
91
- if "model=" in lines[i] or "model =" in lines[i]), None)
92
- if model_idx is not None:
93
- old = lines[model_idx]
94
- indent = old[: len(old) - len(old.lstrip())]
95
- trailing = "," if old.rstrip().endswith(",") else ""
96
- context = lines[model_idx - 1] if model_idx else ""
97
- out += [
98
- ("hunk", f"@@ -{model_idx},2 +{model_idx},2 @@"),
99
- ("ctx", f" {context}"),
100
- ("del", f"-{old}"),
101
- ("add", f"+{indent}model=os.environ.get(\"NABLA_MODEL_{env_suffix}\", "
102
- f"\"{deployment['model']}\"){trailing}"),
103
- ]
104
-
105
- # Hunk 3: credentials, appended to the repo's .env.
106
- out += [
107
- ("head", "--- a/.env"),
108
- ("head", "+++ b/.env"),
109
- ("add", f"+{deployment['api_key_env']}=fsk-................ (provisioned by backend)"),
110
- ("add", f"+NABLA_BASE_URL={deployment['base_url']}"),
111
- ("add", f"+NABLA_MODEL_{env_suffix}={deployment['model']}"),
112
- ]
113
- return out
114
-
115
-
116
- def pace(seconds: float, interactive: bool) -> None:
117
- """Small dramatic pause, interactive terminals only."""
118
- if interactive:
119
- time.sleep(seconds)
1
+ """`nabla pull` — UX simulation of the loop-closing command (cli-plan §7).
2
+
3
+ The deployments API doesn't exist yet, so this module renders the full
4
+ intended experience — deployment table, per-site diffs, apply + verify,
5
+ before/after report — as a faithful simulation that NEVER writes anything:
6
+ no file edits, no .env changes, no network calls. Diffs are built from the
7
+ target repo's real source lines so the preview is true to life; deployment
8
+ rows are fabricated deterministically from the site symbol.
9
+
10
+ When the real backend lands, the fabrication points (_mock_deployments,
11
+ the apply/verify steps) get replaced by API calls and real edits; the
12
+ rendering stays.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from .config import load_config
22
+
23
+ DEFAULT_ENDPOINT = "http://127.0.0.1:8000/deployments"
24
+ _BASE_URL = "https://api.freesolo.ai/v1"
25
+ _KEY_ENV = "NABLA_MODEL_API_KEY"
26
+
27
+
28
+ def resolve_endpoint(flag_value: str | None = None) -> str:
29
+ if flag_value:
30
+ return flag_value
31
+ env = os.environ.get("NABLA_PULL_ENDPOINT")
32
+ if env:
33
+ return env
34
+ stored = load_config().get("pull_endpoint")
35
+ if stored:
36
+ return stored
37
+ return DEFAULT_ENDPOINT
38
+
39
+
40
+ def mock_deployment(symbol: str, replaces: str) -> dict:
41
+ """A deterministic, plausible deployment row for one site."""
42
+ slug = symbol.replace("_", "-")
43
+ seed = sum(ord(c) for c in symbol)
44
+ return {
45
+ "symbol": symbol,
46
+ "status": "ready",
47
+ "model": f"freesolo/qwen3.5-0.8b-ft-{slug}",
48
+ "base_url": _BASE_URL,
49
+ "api_key_env": _KEY_ENV,
50
+ "replaces": replaces or "unknown",
51
+ "pass_rate": round(0.88 + (seed % 10) / 100, 2),
52
+ "schema_valid_rate": 1.0,
53
+ }
54
+
55
+
56
+ def build_diff(repo: str, site, deployment: dict) -> list[tuple[str, str]]:
57
+ """(style, line) pairs for a unified diff of the intended rewrite,
58
+ grounded in the real source. Read-only."""
59
+ path = Path(repo) / site.file
60
+ try:
61
+ lines = path.read_text(encoding="utf-8").splitlines()
62
+ except OSError:
63
+ return []
64
+
65
+ env_suffix = site.symbol.upper()
66
+ out: list[tuple[str, str]] = [
67
+ ("head", f"--- a/{site.file}"),
68
+ ("head", f"+++ b/{site.file}"),
69
+ ]
70
+
71
+ # Hunk 1: client construction, if a literal OpenAI(...) line exists.
72
+ client_idx = next((i for i, l in enumerate(lines)
73
+ if "OpenAI(" in l and "import" not in l), None)
74
+ if client_idx is not None:
75
+ old = lines[client_idx]
76
+ indent = old[: len(old) - len(old.lstrip())]
77
+ assign = old.split("OpenAI(", 1)[0]
78
+ out += [
79
+ ("hunk", f"@@ -{client_idx + 1},1 +{client_idx + 1},2 @@"),
80
+ ("del", f"-{old}"),
81
+ ("add", f"+{assign}OpenAI("),
82
+ ("add", f"+{indent} base_url=os.environ.get(\"NABLA_BASE_URL\", "
83
+ f"\"{deployment['base_url']}\"),"),
84
+ ("add", f"+{indent} api_key=os.environ[\"{deployment['api_key_env']}\"],"),
85
+ ("add", f"+{indent})"),
86
+ ]
87
+
88
+ # Hunk 2: the model kwarg inside the call span.
89
+ start, end = site.span
90
+ model_idx = next((i for i in range(start - 1, min(end, len(lines)))
91
+ if "model=" in lines[i] or "model =" in lines[i]), None)
92
+ if model_idx is not None:
93
+ old = lines[model_idx]
94
+ indent = old[: len(old) - len(old.lstrip())]
95
+ trailing = "," if old.rstrip().endswith(",") else ""
96
+ context = lines[model_idx - 1] if model_idx else ""
97
+ out += [
98
+ ("hunk", f"@@ -{model_idx},2 +{model_idx},2 @@"),
99
+ ("ctx", f" {context}"),
100
+ ("del", f"-{old}"),
101
+ ("add", f"+{indent}model=os.environ.get(\"NABLA_MODEL_{env_suffix}\", "
102
+ f"\"{deployment['model']}\"){trailing}"),
103
+ ]
104
+
105
+ # Hunk 3: credentials, appended to the repo's .env.
106
+ out += [
107
+ ("head", "--- a/.env"),
108
+ ("head", "+++ b/.env"),
109
+ ("add", f"+{deployment['api_key_env']}=fsk-................ (provisioned by backend)"),
110
+ ("add", f"+NABLA_BASE_URL={deployment['base_url']}"),
111
+ ("add", f"+NABLA_MODEL_{env_suffix}={deployment['model']}"),
112
+ ]
113
+ return out
114
+
115
+
116
+ def pace(seconds: float, interactive: bool) -> None:
117
+ """Small dramatic pause, interactive terminals only."""
118
+ if interactive:
119
+ time.sleep(seconds)
120
+
121
+
122
+ class FillerSite:
123
+ """Stand-in site so single-site repos still demo a multi-row pull."""
124
+
125
+ symbol = "extract_invoice"
126
+ file = "app/enrichment.py"
127
+ span = (41, 58)
128
+
129
+
130
+ def filler_deployment() -> tuple[FillerSite, dict]:
131
+ site = FillerSite()
132
+ dep = mock_deployment(site.symbol, "gpt-4o")
133
+ return site, dep
134
+
135
+
136
+ def filler_diff(dep: dict) -> list[tuple[str, str]]:
137
+ """Canned diff for the filler site (its file doesn't exist on disk)."""
138
+ return [
139
+ ("head", f"--- a/{FillerSite.file}"),
140
+ ("head", f"+++ b/{FillerSite.file}"),
141
+ ("hunk", "@@ -44,2 +44,2 @@"),
142
+ ("ctx", " response = client.chat.completions.create("),
143
+ ("del", '- model="gpt-4o",'),
144
+ ("add", f'+ model=os.environ.get("NABLA_MODEL_EXTRACT_INVOICE", "{dep["model"]}"),'),
145
+ ("head", "--- a/.env"),
146
+ ("head", "+++ b/.env"),
147
+ ("add", f"+NABLA_MODEL_EXTRACT_INVOICE={dep['model']}"),
148
+ ]
@@ -50,9 +50,15 @@ def resolve_endpoint(flag_value: str | None = None) -> str:
50
50
 
51
51
  def to_run_task(kind: str, artifact: dict) -> dict:
52
52
  """Translate a build/jeremy artifact into the dispatcher's
53
- RunTaskRequest shape. Pure rearrangement — nothing invented."""
53
+ RunTaskRequest shape. Pure rearrangement — nothing invented.
54
+
55
+ site_id (the profile/seed's stable template+schema hash) rides along so
56
+ the backend can key the eventual fine-tuned deployment to the exact
57
+ call site — `nabla pull` joins on it later. Never file:line, never the
58
+ bare symbol: both are ambiguous across drift and duplicates."""
54
59
  if kind == "jeremy":
55
60
  return {
61
+ "site_id": artifact.get("site_id"),
56
62
  "goal": artifact.get("goal", ""),
57
63
  "examples": artifact.get("examples", []),
58
64
  "student_system_prompt": artifact.get("system_prompt", "") or "",
@@ -65,6 +71,7 @@ def to_run_task(kind: str, artifact: dict) -> dict:
65
71
  + [str(c).rstrip(".") + "." for c in goal_block.get("constraints", [])]
66
72
  ).strip()
67
73
  return {
74
+ "site_id": artifact.get("site_id"),
68
75
  "goal": goal_text,
69
76
  "examples": [
70
77
  {"problem": e.get("prompt", ""), "solution": e.get("completion", "")}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.9.0
3
+ Version: 0.9.2
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.9.0"
3
+ version = "0.9.2"
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" }
@@ -547,7 +547,9 @@ def test_jeremy_single_site_writes_seed_spec(tmp_path, monkeypatch, capsys):
547
547
 
548
548
  assert rc == 0
549
549
  seed = json.loads((tmp_path / "extract_receipt.jeremy.json").read_text(encoding="utf-8"))
550
- assert set(seed) == {"goal", "system_prompt", "examples"}
550
+ assert set(seed) == {"site_id", "symbol", "goal", "system_prompt", "examples"}
551
+ assert len(seed["site_id"]) == 64 # matches the profile's stable hash
552
+ assert seed["symbol"] == "extract_receipt"
551
553
  assert seed["goal"].startswith("Solve a grade-school word problem")
552
554
  assert "final answer is a bare number" in seed["goal"]
553
555
  assert "Extract the receipt fields" in seed["system_prompt"]
@@ -1,76 +1,79 @@
1
- """Tests for the nabla pull simulation — its one hard guarantee: it never
2
- writes anything."""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
-
8
- import pytest
9
-
10
- from nabla.cli import main
11
-
12
- from test_cli_ux import _SINGLE_SITE_SRC
13
-
14
-
15
- @pytest.fixture(autouse=True)
16
- def _clean_env(monkeypatch, tmp_path):
17
- monkeypatch.setattr("nabla.config.config_path", lambda: tmp_path / "no-config.json")
18
- monkeypatch.delenv("NABLA_PULL_ENDPOINT", raising=False)
19
-
20
-
21
- def _snapshot(root):
22
- return {p: p.read_bytes() for p in root.rglob("*") if p.is_file()}
23
-
24
-
25
- def test_pull_dry_run_prints_diff_and_writes_nothing(tmp_path, monkeypatch, capsys):
26
- repo = tmp_path / "repo"
27
- repo.mkdir()
28
- (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
29
- monkeypatch.chdir(tmp_path)
30
- before = _snapshot(tmp_path)
31
-
32
- rc = main(["pull", str(repo)])
33
-
34
- assert rc == 0
35
- assert _snapshot(tmp_path) == before # nothing created or modified
36
- out = capsys.readouterr().out
37
- assert "freesolo/qwen3.5-0.8b-ft-extract-receipt" in out
38
- assert "gpt-4o-mini" in out # REPLACES column from real sampling
39
- assert "--- a/single_call_site.py" in out
40
- assert "+++ b/.env" in out
41
- assert "dry run" in out
42
- assert "simulation — nothing was written" in out
43
-
44
-
45
- def test_pull_apply_simulates_and_writes_nothing(tmp_path, monkeypatch, capsys):
46
- repo = tmp_path / "repo"
47
- repo.mkdir()
48
- (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
49
- (tmp_path / "extract_receipt.profile.json").write_text(json.dumps(
50
- {"baseline": {"cost_per_call_usd": 0.011, "p50_latency_ms": 2000,
51
- "calls_observed": 5}}), encoding="utf-8")
52
- monkeypatch.chdir(tmp_path)
53
- before = _snapshot(tmp_path)
54
-
55
- rc = main(["pull", str(repo), "--apply"])
56
-
57
- assert rc == 0
58
- assert _snapshot(tmp_path) == before
59
- out = capsys.readouterr().out
60
- assert "2 edits -> single_call_site.py" in out
61
- assert "completions valid" in out
62
- assert "$0.011000 -> $0.001000" in out
63
- assert "rollback: unset NABLA_MODEL_EXTRACT_RECEIPT" in out
64
- assert "simulation — nothing was written" in out
65
-
66
-
67
- def test_pull_unknown_site_exits_2(tmp_path, monkeypatch, capsys):
68
- repo = tmp_path / "repo"
69
- repo.mkdir()
70
- (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
71
- monkeypatch.chdir(tmp_path)
72
-
73
- rc = main(["pull", str(repo), "--site", "nope"])
74
-
75
- assert rc == 2
76
- assert "no supported site" in capsys.readouterr().err
1
+ """Tests for the nabla pull simulation — its one hard guarantee: it never
2
+ writes anything."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from nabla.cli import main
11
+
12
+ from test_cli_ux import _SINGLE_SITE_SRC
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _clean_env(monkeypatch, tmp_path):
17
+ monkeypatch.setattr("nabla.config.config_path", lambda: tmp_path / "no-config.json")
18
+ monkeypatch.delenv("NABLA_PULL_ENDPOINT", raising=False)
19
+
20
+
21
+ def _snapshot(root):
22
+ return {p: p.read_bytes() for p in root.rglob("*") if p.is_file()}
23
+
24
+
25
+ def test_pull_dry_run_prints_diff_and_writes_nothing(tmp_path, monkeypatch, capsys):
26
+ repo = tmp_path / "repo"
27
+ repo.mkdir()
28
+ (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
29
+ monkeypatch.chdir(tmp_path)
30
+ before = _snapshot(tmp_path)
31
+
32
+ rc = main(["pull", str(repo)])
33
+
34
+ assert rc == 0
35
+ assert _snapshot(tmp_path) == before # nothing created or modified
36
+ out = capsys.readouterr().out
37
+ assert "freesolo/qwen3.5-0.8b-ft-extract-receipt" in out
38
+ assert "gpt-4o-mini" in out # REPLACES column from real sampling
39
+ assert "--- a/single_call_site.py" in out
40
+ assert "+++ b/.env" in out
41
+ assert "dry run" in out
42
+ assert "simulation — nothing was written" in out
43
+ # single-site repos are padded with a canned second deployment
44
+ assert "extract_invoice" in out
45
+ assert "freesolo/qwen3.5-0.8b-ft-extract-invoice" in out
46
+
47
+
48
+ def test_pull_apply_simulates_and_writes_nothing(tmp_path, monkeypatch, capsys):
49
+ repo = tmp_path / "repo"
50
+ repo.mkdir()
51
+ (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
52
+ (tmp_path / "extract_receipt.profile.json").write_text(json.dumps(
53
+ {"baseline": {"cost_per_call_usd": 0.011, "p50_latency_ms": 2000,
54
+ "calls_observed": 5}}), encoding="utf-8")
55
+ monkeypatch.chdir(tmp_path)
56
+ before = _snapshot(tmp_path)
57
+
58
+ rc = main(["pull", str(repo), "--apply"])
59
+
60
+ assert rc == 0
61
+ assert _snapshot(tmp_path) == before
62
+ out = capsys.readouterr().out
63
+ assert "2 edits -> single_call_site.py" in out
64
+ assert "completions valid" in out
65
+ assert "$0.011000 -> $0.001000" in out
66
+ assert "rollback: unset NABLA_MODEL_EXTRACT_RECEIPT" in out
67
+ assert "simulation nothing was written" in out
68
+
69
+
70
+ def test_pull_unknown_site_exits_2(tmp_path, monkeypatch, capsys):
71
+ repo = tmp_path / "repo"
72
+ repo.mkdir()
73
+ (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
74
+ monkeypatch.chdir(tmp_path)
75
+
76
+ rc = main(["pull", str(repo), "--site", "nope"])
77
+
78
+ assert rc == 2
79
+ assert "no supported site" in capsys.readouterr().err
@@ -70,6 +70,7 @@ def test_push_artifact_translates_jeremy_to_run_task():
70
70
  ok, detail = push_artifact(backend.url, "s", "jeremy", artifact)
71
71
  assert ok and "200" in detail
72
72
  assert backend.received == [{
73
+ "site_id": None,
73
74
  "goal": "Solve problems.",
74
75
  "examples": [{"problem": "p", "solution": "s"}],
75
76
  "student_system_prompt": "You are a tutor.",
@@ -114,7 +115,8 @@ def test_cmd_push_sweeps_site_artifacts(tmp_path, monkeypatch, capsys):
114
115
 
115
116
  assert rc == 0
116
117
  assert len(backend.received) == 2
117
- assert all(set(r) == {"goal", "examples", "student_system_prompt", "student_goal"}
118
+ assert all(set(r) == {"site_id", "goal", "examples",
119
+ "student_system_prompt", "student_goal"}
118
120
  for r in backend.received)
119
121
  assert "pushed 2 artifact(s)" in capsys.readouterr().out
120
122
 
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