nabla-cli 0.8.0__tar.gz → 0.9.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.8.0 → nabla_cli-0.9.0}/PKG-INFO +30 -9
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/README.md +29 -8
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/__init__.py +1 -1
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/cli.py +145 -3
- nabla_cli-0.9.0/nabla/puller.py +119 -0
- nabla_cli-0.9.0/nabla/pusher.py +121 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla_cli.egg-info/PKG-INFO +30 -9
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla_cli.egg-info/SOURCES.txt +2 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/pyproject.toml +1 -1
- nabla_cli-0.9.0/tests/test_pull.py +76 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_push.py +144 -119
- nabla_cli-0.8.0/nabla/pusher.py +0 -68
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/branding.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/config.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/contract.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/induce.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/profile.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/recorder.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/samplegen.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/term.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla/ui.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/setup.cfg +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_cli_ux.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_config.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.0}/tests/test_scanner.py +0 -0
- {nabla_cli-0.8.0 → nabla_cli-0.9.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.9.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
|
|
@@ -182,14 +182,35 @@ output doesn't contain.
|
|
|
182
182
|
|
|
183
183
|
### `nabla push`
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
Hands built artifacts to the backend dispatcher, which runs the
|
|
186
|
+
seed-generation agent pool against them: every `<site>.profile.json` and
|
|
187
|
+
`<site>.jeremy.json` found for the repo's supported sites (narrow with
|
|
188
|
+
`--site`, or push exact files with `--artifact`). Artifacts are
|
|
189
|
+
translated into the dispatcher's contract — `{goal, examples,
|
|
190
|
+
student_system_prompt, student_goal}` — where `student_system_prompt` is
|
|
191
|
+
the site's own production system prompt. A push runs the full pool
|
|
192
|
+
server-side and may take minutes.
|
|
193
|
+
|
|
194
|
+
The endpoint defaults to the local dispatcher
|
|
195
|
+
(`http://127.0.0.1:8000/run-task`; start it from `backend/` with
|
|
196
|
+
`langgraph dev` and `uvicorn seed_generator.server:app --port 8000`) and
|
|
197
|
+
is meant to change later — resolution order: `--endpoint` flag, then
|
|
198
|
+
`NABLA_PUSH_ENDPOINT`, then `push_endpoint` in `~/.nabla/config.json`,
|
|
199
|
+
then the default.
|
|
200
|
+
|
|
201
|
+
### `nabla pull` (preview)
|
|
202
|
+
|
|
203
|
+
The loop-closer: fetches ready fine-tuned deployments and rewrites each
|
|
204
|
+
harvested call site to run on your model — client `base_url` +
|
|
205
|
+
`model` swap with env-wired credentials, rollback via one `unset`.
|
|
206
|
+
Dry-run by default (diff only); `--apply` performs the edits and
|
|
207
|
+
verifies the site live against the new endpoint, then prints the
|
|
208
|
+
before/after cost and latency.
|
|
209
|
+
|
|
210
|
+
Currently a full UX simulation while the deployments API is built:
|
|
211
|
+
every run renders the real intended flow (diffs come from your repo's
|
|
212
|
+
actual source) but writes nothing — each run ends with an explicit
|
|
213
|
+
"simulation — nothing was written" line.
|
|
193
214
|
|
|
194
215
|
## Providers
|
|
195
216
|
|
|
@@ -168,14 +168,35 @@ output doesn't contain.
|
|
|
168
168
|
|
|
169
169
|
### `nabla push`
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
Hands built artifacts to the backend dispatcher, which runs the
|
|
172
|
+
seed-generation agent pool against them: every `<site>.profile.json` and
|
|
173
|
+
`<site>.jeremy.json` found for the repo's supported sites (narrow with
|
|
174
|
+
`--site`, or push exact files with `--artifact`). Artifacts are
|
|
175
|
+
translated into the dispatcher's contract — `{goal, examples,
|
|
176
|
+
student_system_prompt, student_goal}` — where `student_system_prompt` is
|
|
177
|
+
the site's own production system prompt. A push runs the full pool
|
|
178
|
+
server-side and may take minutes.
|
|
179
|
+
|
|
180
|
+
The endpoint defaults to the local dispatcher
|
|
181
|
+
(`http://127.0.0.1:8000/run-task`; start it from `backend/` with
|
|
182
|
+
`langgraph dev` and `uvicorn seed_generator.server:app --port 8000`) and
|
|
183
|
+
is meant to change later — resolution order: `--endpoint` flag, then
|
|
184
|
+
`NABLA_PUSH_ENDPOINT`, then `push_endpoint` in `~/.nabla/config.json`,
|
|
185
|
+
then the default.
|
|
186
|
+
|
|
187
|
+
### `nabla pull` (preview)
|
|
188
|
+
|
|
189
|
+
The loop-closer: fetches ready fine-tuned deployments and rewrites each
|
|
190
|
+
harvested call site to run on your model — client `base_url` +
|
|
191
|
+
`model` swap with env-wired credentials, rollback via one `unset`.
|
|
192
|
+
Dry-run by default (diff only); `--apply` performs the edits and
|
|
193
|
+
verifies the site live against the new endpoint, then prints the
|
|
194
|
+
before/after cost and latency.
|
|
195
|
+
|
|
196
|
+
Currently a full UX simulation while the deployments API is built:
|
|
197
|
+
every run renders the real intended flow (diffs come from your repo's
|
|
198
|
+
actual source) but writes nothing — each run ends with an explicit
|
|
199
|
+
"simulation — nothing was written" line.
|
|
179
200
|
|
|
180
201
|
## Providers
|
|
181
202
|
|
|
@@ -158,7 +158,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
158
158
|
|
|
159
159
|
p_push = sub.add_parser(
|
|
160
160
|
"push",
|
|
161
|
-
help="send build/jeremy artifacts to the
|
|
161
|
+
help="send build/jeremy artifacts to the backend dispatcher (seed generation)",
|
|
162
162
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
163
163
|
epilog=(
|
|
164
164
|
"pushes every <site>.profile.json / <site>.jeremy.json found for the\n"
|
|
@@ -178,6 +178,30 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
178
178
|
p_push.add_argument("--endpoint", default=None,
|
|
179
179
|
help="backend URL (default: local dev server; see epilog for resolution order)")
|
|
180
180
|
|
|
181
|
+
p_pull = sub.add_parser(
|
|
182
|
+
"pull",
|
|
183
|
+
help="swap harvested sites onto their fine-tuned models (simulation)",
|
|
184
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
185
|
+
epilog=(
|
|
186
|
+
"fetches ready deployments and rewrites each harvested call site to run\n"
|
|
187
|
+
"on your fine-tuned model (base_url + model swap, env-wired credentials).\n"
|
|
188
|
+
"dry-run by default: shows the diff, writes nothing. --apply performs the\n"
|
|
189
|
+
"edits and verifies live. Currently a full UX simulation — the\n"
|
|
190
|
+
"deployments API is not live yet, and nothing is ever written.\n\n"
|
|
191
|
+
"examples:\n"
|
|
192
|
+
" nabla pull\n"
|
|
193
|
+
" nabla pull --site solve_problem --apply\n"
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
p_pull.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
|
|
197
|
+
p_pull.add_argument("--site", help="only this site")
|
|
198
|
+
p_pull.add_argument("--apply", action="store_true",
|
|
199
|
+
help="perform the edits (simulated) and run the live verify")
|
|
200
|
+
p_pull.add_argument("--no-verify", action="store_true",
|
|
201
|
+
help="with --apply: skip the post-apply live check")
|
|
202
|
+
p_pull.add_argument("--endpoint", default=None,
|
|
203
|
+
help="deployments API URL (default: local backend)")
|
|
204
|
+
|
|
181
205
|
return parser
|
|
182
206
|
|
|
183
207
|
|
|
@@ -241,6 +265,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
241
265
|
"profile": _cmd_profile, # legacy alias
|
|
242
266
|
"jeremy": lambda a: _cmd_profile(a, mode="jeremy"),
|
|
243
267
|
"push": _cmd_push,
|
|
268
|
+
"pull": _cmd_pull,
|
|
244
269
|
}
|
|
245
270
|
try:
|
|
246
271
|
return commands[args.command](args)
|
|
@@ -1015,13 +1040,130 @@ def _cmd_push(args) -> int:
|
|
|
1015
1040
|
|
|
1016
1041
|
rail(_out)
|
|
1017
1042
|
if failed and not pushed:
|
|
1018
|
-
rail_end(_out, f"push failed — is the
|
|
1019
|
-
"
|
|
1043
|
+
rail_end(_out, f"push failed — is the dispatcher running at {endpoint}? "
|
|
1044
|
+
"start it from backend/ (langgraph dev, then uvicorn "
|
|
1045
|
+
"seed_generator.server:app --port 8000), or override with "
|
|
1046
|
+
"--endpoint / NABLA_PUSH_ENDPOINT", style="red")
|
|
1020
1047
|
return 1
|
|
1021
1048
|
summary = f"pushed {pushed} artifact(s)" + (f", {failed} failed" if failed else "")
|
|
1022
1049
|
rail_end(_out, summary, style="green" if not failed else "yellow")
|
|
1023
1050
|
return 0 if not failed else 1
|
|
1024
1051
|
|
|
1025
1052
|
|
|
1053
|
+
def _cmd_pull(args) -> int:
|
|
1054
|
+
from rich import box as rich_box
|
|
1055
|
+
from rich.table import Table
|
|
1056
|
+
from rich.text import Text
|
|
1057
|
+
|
|
1058
|
+
from .profile import extract_sampling
|
|
1059
|
+
from .puller import build_diff, mock_deployment, pace, resolve_endpoint
|
|
1060
|
+
from .ui import make_progress, rail, rail_end, rail_ok, rail_start
|
|
1061
|
+
|
|
1062
|
+
interactive = _out.is_terminal
|
|
1063
|
+
rail_start(_out, "nabla pull")
|
|
1064
|
+
endpoint = resolve_endpoint(args.endpoint)
|
|
1065
|
+
rail(_out, f"endpoint {endpoint}")
|
|
1066
|
+
|
|
1067
|
+
sites = scan(args.repo)
|
|
1068
|
+
supported = [s for s in sites
|
|
1069
|
+
if is_supported(resolve_schema(s, args.repo)[1], s.flags)]
|
|
1070
|
+
if args.site:
|
|
1071
|
+
supported = [s for s in supported if s.symbol == args.site]
|
|
1072
|
+
if not supported:
|
|
1073
|
+
_fail(f"nabla pull: no supported site named {args.site!r}")
|
|
1074
|
+
return 2
|
|
1075
|
+
if not supported:
|
|
1076
|
+
_fail(f"nabla pull: no supported call sites in {args.repo!r}")
|
|
1077
|
+
return 2
|
|
1078
|
+
|
|
1079
|
+
if interactive:
|
|
1080
|
+
with _out.status("fetching deployments", spinner="dots"):
|
|
1081
|
+
pace(0.6, interactive)
|
|
1082
|
+
deployments = []
|
|
1083
|
+
for site in supported:
|
|
1084
|
+
replaces = extract_sampling(site, args.repo).get("model") or "unknown"
|
|
1085
|
+
deployments.append((site, mock_deployment(site.symbol, replaces)))
|
|
1086
|
+
|
|
1087
|
+
rail(_out)
|
|
1088
|
+
table = Table(box=rich_box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
|
|
1089
|
+
for col in ("SITE", "FINE-TUNED MODEL", "REPLACES", "PASS RATE", "STATUS"):
|
|
1090
|
+
table.add_column(col, overflow="fold")
|
|
1091
|
+
for site, dep in deployments:
|
|
1092
|
+
table.add_row(
|
|
1093
|
+
Text(site.symbol, style="cyan"),
|
|
1094
|
+
dep["model"],
|
|
1095
|
+
dep["replaces"],
|
|
1096
|
+
f"{dep['pass_rate']:.0%}",
|
|
1097
|
+
Text("ready", style="green"),
|
|
1098
|
+
)
|
|
1099
|
+
_out.print(table)
|
|
1100
|
+
|
|
1101
|
+
for site, dep in deployments:
|
|
1102
|
+
rail(_out)
|
|
1103
|
+
rail(_out, f"{site.symbol} · {site.file}", style="bold")
|
|
1104
|
+
for style, line in build_diff(args.repo, site, dep):
|
|
1105
|
+
color = {"add": "green", "del": "red", "hunk": "cyan",
|
|
1106
|
+
"head": "bold", "ctx": ""}[style]
|
|
1107
|
+
rail(_out, line, style=color)
|
|
1108
|
+
|
|
1109
|
+
if not args.apply:
|
|
1110
|
+
rail(_out)
|
|
1111
|
+
rail_end(_out, "dry run — rerun with --apply to write these edits")
|
|
1112
|
+
_out.print("simulation — nothing was written (deployments API not live yet)",
|
|
1113
|
+
style="dim", markup=False)
|
|
1114
|
+
return 0
|
|
1115
|
+
|
|
1116
|
+
# --apply: simulated edits, then the live verify.
|
|
1117
|
+
rail(_out)
|
|
1118
|
+
for site, dep in deployments:
|
|
1119
|
+
pace(0.35, interactive)
|
|
1120
|
+
rail_ok(_out, f"{site.symbol}: 2 edits -> {site.file}")
|
|
1121
|
+
pace(0.25, interactive)
|
|
1122
|
+
rail_ok(_out, f"credentials -> .env ({len(deployments) * 2 + 1} vars, gitignored ✓)")
|
|
1123
|
+
|
|
1124
|
+
if not args.no_verify:
|
|
1125
|
+
rail(_out)
|
|
1126
|
+
rail(_out, "verifying against the fine-tuned endpoint", style="bold")
|
|
1127
|
+
for site, dep in deployments:
|
|
1128
|
+
inputs = Path(f"{site.symbol}.inputs.jsonl")
|
|
1129
|
+
try:
|
|
1130
|
+
total = sum(1 for l in inputs.read_text(encoding="utf-8").splitlines()
|
|
1131
|
+
if l.strip()) or 5
|
|
1132
|
+
except OSError:
|
|
1133
|
+
total = 5
|
|
1134
|
+
if interactive:
|
|
1135
|
+
progress = make_progress(_out, unit="checks")
|
|
1136
|
+
with progress:
|
|
1137
|
+
task = progress.add_task(site.symbol, total=total)
|
|
1138
|
+
for i in range(total):
|
|
1139
|
+
pace(0.3, interactive)
|
|
1140
|
+
progress.update(task, completed=i + 1)
|
|
1141
|
+
rail_ok(_out, f"{site.symbol}: {total}/{total} completions valid "
|
|
1142
|
+
"against output_schema")
|
|
1143
|
+
|
|
1144
|
+
rail(_out)
|
|
1145
|
+
for site, dep in deployments:
|
|
1146
|
+
profile_path = Path(f"{site.symbol}.profile.json")
|
|
1147
|
+
before_cost, before_p50 = 0.00247, 1097
|
|
1148
|
+
try:
|
|
1149
|
+
baseline = json.loads(profile_path.read_text(encoding="utf-8"))["baseline"]
|
|
1150
|
+
before_cost = baseline["cost_per_call_usd"] or before_cost
|
|
1151
|
+
before_p50 = baseline["p50_latency_ms"] or before_p50
|
|
1152
|
+
except (OSError, KeyError, json.JSONDecodeError):
|
|
1153
|
+
pass
|
|
1154
|
+
after_cost, after_p50 = before_cost / 11, int(before_p50 * 0.55)
|
|
1155
|
+
rail(_out, f"{site.symbol} cost/call ${before_cost:.6f} -> ${after_cost:.6f} "
|
|
1156
|
+
f"(-{1 - after_cost / before_cost:.0%})")
|
|
1157
|
+
rail(_out, f"{' ' * len(site.symbol)} p50 {before_p50}ms -> {after_p50}ms")
|
|
1158
|
+
|
|
1159
|
+
rail(_out)
|
|
1160
|
+
rollback = deployments[0][0].symbol.upper()
|
|
1161
|
+
rail_end(_out, f"done — {len(deployments)} site(s) now run on your models "
|
|
1162
|
+
f"(rollback: unset NABLA_MODEL_{rollback} and restart)", style="green")
|
|
1163
|
+
_out.print("simulation — nothing was written (deployments API not live yet)",
|
|
1164
|
+
style="dim", markup=False)
|
|
1165
|
+
return 0
|
|
1166
|
+
|
|
1167
|
+
|
|
1026
1168
|
if __name__ == "__main__":
|
|
1027
1169
|
sys.exit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
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)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""`nabla push` transport: hand build/jeremy artifacts to the backend
|
|
2
|
+
dispatcher (backend/seed_generator/server.py, POST /run-task), which fires
|
|
3
|
+
the Phase 0 seed-generation agents against them.
|
|
4
|
+
|
|
5
|
+
The endpoint is deliberately swappable — the local dispatcher today, a
|
|
6
|
+
hosted service later. Resolution order (first hit wins):
|
|
7
|
+
|
|
8
|
+
1. --endpoint flag
|
|
9
|
+
2. NABLA_PUSH_ENDPOINT env var
|
|
10
|
+
3. "push_endpoint" in ~/.nabla/config.json
|
|
11
|
+
4. DEFAULT_ENDPOINT (the local dispatcher)
|
|
12
|
+
|
|
13
|
+
Both artifact kinds are translated into the dispatcher's RunTaskRequest
|
|
14
|
+
contract before sending:
|
|
15
|
+
|
|
16
|
+
{"goal": str, "examples": [{"problem", "solution"}, ...],
|
|
17
|
+
"student_system_prompt": str, "student_goal": str}
|
|
18
|
+
|
|
19
|
+
`goal` steers the authoring LLM; `student_system_prompt` is the site's own
|
|
20
|
+
production system prompt — the persona the fine-tuned student will really
|
|
21
|
+
be queried under. A run executes the full agent pool server-side and can
|
|
22
|
+
take minutes; the timeout is sized accordingly (override via
|
|
23
|
+
NABLA_PUSH_TIMEOUT seconds).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
|
|
33
|
+
from .config import load_config
|
|
34
|
+
|
|
35
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:8000/run-task"
|
|
36
|
+
DEFAULT_TIMEOUT = 900 # the dispatcher runs the whole agent pool before replying
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_endpoint(flag_value: str | None = None) -> str:
|
|
40
|
+
if flag_value:
|
|
41
|
+
return flag_value
|
|
42
|
+
env = os.environ.get("NABLA_PUSH_ENDPOINT")
|
|
43
|
+
if env:
|
|
44
|
+
return env
|
|
45
|
+
stored = load_config().get("push_endpoint")
|
|
46
|
+
if stored:
|
|
47
|
+
return stored
|
|
48
|
+
return DEFAULT_ENDPOINT
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_run_task(kind: str, artifact: dict) -> dict:
|
|
52
|
+
"""Translate a build/jeremy artifact into the dispatcher's
|
|
53
|
+
RunTaskRequest shape. Pure rearrangement — nothing invented."""
|
|
54
|
+
if kind == "jeremy":
|
|
55
|
+
return {
|
|
56
|
+
"goal": artifact.get("goal", ""),
|
|
57
|
+
"examples": artifact.get("examples", []),
|
|
58
|
+
"student_system_prompt": artifact.get("system_prompt", "") or "",
|
|
59
|
+
"student_goal": artifact.get("goal", ""),
|
|
60
|
+
}
|
|
61
|
+
# profile: same information, different layout.
|
|
62
|
+
goal_block = artifact.get("goal", {}) or {}
|
|
63
|
+
goal_text = " ".join(
|
|
64
|
+
[str(goal_block.get("description", "")).rstrip(".") + "."]
|
|
65
|
+
+ [str(c).rstrip(".") + "." for c in goal_block.get("constraints", [])]
|
|
66
|
+
).strip()
|
|
67
|
+
return {
|
|
68
|
+
"goal": goal_text,
|
|
69
|
+
"examples": [
|
|
70
|
+
{"problem": e.get("prompt", ""), "solution": e.get("completion", "")}
|
|
71
|
+
for e in artifact.get("examples", [])
|
|
72
|
+
],
|
|
73
|
+
"student_system_prompt": (artifact.get("prompt", {}) or {}).get("system_prompt") or "",
|
|
74
|
+
"student_goal": goal_text,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def push_artifact(endpoint: str, site: str, kind: str, artifact: dict,
|
|
79
|
+
timeout: int | None = None) -> tuple[bool, str]:
|
|
80
|
+
"""Translate + POST one artifact. Returns (ok, detail) — never raises."""
|
|
81
|
+
if timeout is None:
|
|
82
|
+
timeout = int(os.environ.get("NABLA_PUSH_TIMEOUT", str(DEFAULT_TIMEOUT)))
|
|
83
|
+
payload = json.dumps(to_run_task(kind, artifact)).encode("utf-8")
|
|
84
|
+
req = urllib.request.Request(
|
|
85
|
+
endpoint,
|
|
86
|
+
data=payload,
|
|
87
|
+
method="POST",
|
|
88
|
+
headers={"Content-Type": "application/json", "User-Agent": "nabla-push/0.1"},
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
92
|
+
body = resp.read().decode("utf-8", "replace")
|
|
93
|
+
summary = _summarize_response(body)
|
|
94
|
+
return True, f"HTTP {resp.status}" + (f" · {summary}" if summary else "")
|
|
95
|
+
except urllib.error.HTTPError as exc:
|
|
96
|
+
try:
|
|
97
|
+
detail = exc.read().decode("utf-8", "replace")[:200]
|
|
98
|
+
except Exception: # noqa: BLE001 - body read is best-effort
|
|
99
|
+
detail = ""
|
|
100
|
+
return False, f"HTTP {exc.code}" + (f" · {detail}" if detail else f" · {exc.reason}")
|
|
101
|
+
except urllib.error.URLError as exc:
|
|
102
|
+
return False, f"unreachable ({exc.reason})"
|
|
103
|
+
except Exception as exc: # noqa: BLE001 - transport must never crash the CLI
|
|
104
|
+
return False, str(exc)[:200]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _summarize_response(body: str) -> str:
|
|
108
|
+
"""One line from the dispatcher's (potentially large) run result."""
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(body)
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
return body[:120].strip()
|
|
113
|
+
if isinstance(data, dict):
|
|
114
|
+
accepted = data.get("accepted")
|
|
115
|
+
dist = data.get("pool_distribution") or {}
|
|
116
|
+
if isinstance(accepted, list):
|
|
117
|
+
return f"{len(accepted)} seed task(s) accepted" + (
|
|
118
|
+
" · pool ok" if dist.get("ok") else ""
|
|
119
|
+
)
|
|
120
|
+
return json.dumps(data)[:120]
|
|
121
|
+
return body[:120].strip()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nabla-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -182,14 +182,35 @@ output doesn't contain.
|
|
|
182
182
|
|
|
183
183
|
### `nabla push`
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
Hands built artifacts to the backend dispatcher, which runs the
|
|
186
|
+
seed-generation agent pool against them: every `<site>.profile.json` and
|
|
187
|
+
`<site>.jeremy.json` found for the repo's supported sites (narrow with
|
|
188
|
+
`--site`, or push exact files with `--artifact`). Artifacts are
|
|
189
|
+
translated into the dispatcher's contract — `{goal, examples,
|
|
190
|
+
student_system_prompt, student_goal}` — where `student_system_prompt` is
|
|
191
|
+
the site's own production system prompt. A push runs the full pool
|
|
192
|
+
server-side and may take minutes.
|
|
193
|
+
|
|
194
|
+
The endpoint defaults to the local dispatcher
|
|
195
|
+
(`http://127.0.0.1:8000/run-task`; start it from `backend/` with
|
|
196
|
+
`langgraph dev` and `uvicorn seed_generator.server:app --port 8000`) and
|
|
197
|
+
is meant to change later — resolution order: `--endpoint` flag, then
|
|
198
|
+
`NABLA_PUSH_ENDPOINT`, then `push_endpoint` in `~/.nabla/config.json`,
|
|
199
|
+
then the default.
|
|
200
|
+
|
|
201
|
+
### `nabla pull` (preview)
|
|
202
|
+
|
|
203
|
+
The loop-closer: fetches ready fine-tuned deployments and rewrites each
|
|
204
|
+
harvested call site to run on your model — client `base_url` +
|
|
205
|
+
`model` swap with env-wired credentials, rollback via one `unset`.
|
|
206
|
+
Dry-run by default (diff only); `--apply` performs the edits and
|
|
207
|
+
verifies the site live against the new endpoint, then prints the
|
|
208
|
+
before/after cost and latency.
|
|
209
|
+
|
|
210
|
+
Currently a full UX simulation while the deployments API is built:
|
|
211
|
+
every run renders the real intended flow (diffs come from your repo's
|
|
212
|
+
actual source) but writes nothing — each run ends with an explicit
|
|
213
|
+
"simulation — nothing was written" line.
|
|
193
214
|
|
|
194
215
|
## Providers
|
|
195
216
|
|
|
@@ -9,6 +9,7 @@ nabla/contract.py
|
|
|
9
9
|
nabla/induce.py
|
|
10
10
|
nabla/profile.py
|
|
11
11
|
nabla/prompt_recon.py
|
|
12
|
+
nabla/puller.py
|
|
12
13
|
nabla/pusher.py
|
|
13
14
|
nabla/recorder.py
|
|
14
15
|
nabla/samplegen.py
|
|
@@ -28,6 +29,7 @@ tests/test_config.py
|
|
|
28
29
|
tests/test_e2e_profile.py
|
|
29
30
|
tests/test_induce.py
|
|
30
31
|
tests/test_prompt_recon.py
|
|
32
|
+
tests/test_pull.py
|
|
31
33
|
tests/test_push.py
|
|
32
34
|
tests/test_recorder.py
|
|
33
35
|
tests/test_samplegen.py
|
|
@@ -0,0 +1,76 @@
|
|
|
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,119 +1,144 @@
|
|
|
1
|
-
"""Tests for nabla push — offline via a local threaded HTTP server."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import http.server
|
|
6
|
-
import json
|
|
7
|
-
import threading
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
from nabla.cli import main
|
|
12
|
-
from nabla.pusher import DEFAULT_ENDPOINT, push_artifact, resolve_endpoint
|
|
13
|
-
|
|
14
|
-
from test_cli_ux import _SINGLE_SITE_SRC
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@pytest.fixture(autouse=True)
|
|
18
|
-
def _clean_env(monkeypatch, tmp_path):
|
|
19
|
-
monkeypatch.setattr("nabla.config.config_path", lambda: tmp_path / "no-config.json")
|
|
20
|
-
monkeypatch.delenv("NABLA_PUSH_ENDPOINT", raising=False)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class _Backend:
|
|
24
|
-
"""Tiny in-process receiver capturing every POST body."""
|
|
25
|
-
|
|
26
|
-
def __init__(self, status: int = 200):
|
|
27
|
-
self.received: list[dict] = []
|
|
28
|
-
backend = self
|
|
29
|
-
|
|
30
|
-
class Handler(http.server.BaseHTTPRequestHandler):
|
|
31
|
-
def log_message(self, *a):
|
|
32
|
-
pass
|
|
33
|
-
|
|
34
|
-
def do_POST(self):
|
|
35
|
-
length = int(self.headers.get("Content-Length") or 0)
|
|
36
|
-
backend.received.append(json.loads(self.rfile.read(length)))
|
|
37
|
-
self.send_response(status)
|
|
38
|
-
self.send_header("Content-Length", "2")
|
|
39
|
-
self.end_headers()
|
|
40
|
-
self.wfile.write(b"ok")
|
|
41
|
-
|
|
42
|
-
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
43
|
-
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
44
|
-
|
|
45
|
-
@property
|
|
46
|
-
def url(self) -> str:
|
|
47
|
-
return f"http://127.0.0.1:{self.server.server_address[1]}/api/profiles"
|
|
48
|
-
|
|
49
|
-
def stop(self):
|
|
50
|
-
self.server.shutdown()
|
|
51
|
-
self.server.server_close()
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_endpoint_resolution_order(monkeypatch, tmp_path):
|
|
55
|
-
assert resolve_endpoint() == DEFAULT_ENDPOINT
|
|
56
|
-
cfg = tmp_path / "config.json"
|
|
57
|
-
cfg.write_text(json.dumps({"push_endpoint": "http://cfg"}), encoding="utf-8")
|
|
58
|
-
monkeypatch.setattr("nabla.config.config_path", lambda: cfg)
|
|
59
|
-
assert resolve_endpoint() == "http://cfg"
|
|
60
|
-
monkeypatch.setenv("NABLA_PUSH_ENDPOINT", "http://env")
|
|
61
|
-
assert resolve_endpoint() == "http://env"
|
|
62
|
-
assert resolve_endpoint("http://flag") == "http://flag"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def
|
|
66
|
-
backend = _Backend()
|
|
67
|
-
try:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
backend.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
assert
|
|
92
|
-
|
|
93
|
-
assert
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
repo
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
assert
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
assert "
|
|
1
|
+
"""Tests for nabla push — offline via a local threaded HTTP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from nabla.cli import main
|
|
12
|
+
from nabla.pusher import DEFAULT_ENDPOINT, push_artifact, resolve_endpoint
|
|
13
|
+
|
|
14
|
+
from test_cli_ux import _SINGLE_SITE_SRC
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def _clean_env(monkeypatch, tmp_path):
|
|
19
|
+
monkeypatch.setattr("nabla.config.config_path", lambda: tmp_path / "no-config.json")
|
|
20
|
+
monkeypatch.delenv("NABLA_PUSH_ENDPOINT", raising=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Backend:
|
|
24
|
+
"""Tiny in-process receiver capturing every POST body."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: int = 200):
|
|
27
|
+
self.received: list[dict] = []
|
|
28
|
+
backend = self
|
|
29
|
+
|
|
30
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
31
|
+
def log_message(self, *a):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def do_POST(self):
|
|
35
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
36
|
+
backend.received.append(json.loads(self.rfile.read(length)))
|
|
37
|
+
self.send_response(status)
|
|
38
|
+
self.send_header("Content-Length", "2")
|
|
39
|
+
self.end_headers()
|
|
40
|
+
self.wfile.write(b"ok")
|
|
41
|
+
|
|
42
|
+
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
43
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def url(self) -> str:
|
|
47
|
+
return f"http://127.0.0.1:{self.server.server_address[1]}/api/profiles"
|
|
48
|
+
|
|
49
|
+
def stop(self):
|
|
50
|
+
self.server.shutdown()
|
|
51
|
+
self.server.server_close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_endpoint_resolution_order(monkeypatch, tmp_path):
|
|
55
|
+
assert resolve_endpoint() == DEFAULT_ENDPOINT
|
|
56
|
+
cfg = tmp_path / "config.json"
|
|
57
|
+
cfg.write_text(json.dumps({"push_endpoint": "http://cfg"}), encoding="utf-8")
|
|
58
|
+
monkeypatch.setattr("nabla.config.config_path", lambda: cfg)
|
|
59
|
+
assert resolve_endpoint() == "http://cfg"
|
|
60
|
+
monkeypatch.setenv("NABLA_PUSH_ENDPOINT", "http://env")
|
|
61
|
+
assert resolve_endpoint() == "http://env"
|
|
62
|
+
assert resolve_endpoint("http://flag") == "http://flag"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_push_artifact_translates_jeremy_to_run_task():
|
|
66
|
+
backend = _Backend()
|
|
67
|
+
try:
|
|
68
|
+
artifact = {"goal": "Solve problems.", "system_prompt": "You are a tutor.",
|
|
69
|
+
"examples": [{"problem": "p", "solution": "s"}]}
|
|
70
|
+
ok, detail = push_artifact(backend.url, "s", "jeremy", artifact)
|
|
71
|
+
assert ok and "200" in detail
|
|
72
|
+
assert backend.received == [{
|
|
73
|
+
"goal": "Solve problems.",
|
|
74
|
+
"examples": [{"problem": "p", "solution": "s"}],
|
|
75
|
+
"student_system_prompt": "You are a tutor.",
|
|
76
|
+
"student_goal": "Solve problems.",
|
|
77
|
+
}]
|
|
78
|
+
finally:
|
|
79
|
+
backend.stop()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_to_run_task_translates_profile():
|
|
83
|
+
from nabla.pusher import to_run_task
|
|
84
|
+
|
|
85
|
+
profile = {
|
|
86
|
+
"goal": {"description": "Extract fields", "constraints": ["null when absent"]},
|
|
87
|
+
"prompt": {"system_prompt": "You are precise."},
|
|
88
|
+
"examples": [{"prompt": "doc text", "completion": "{}", "tokens": {"in": 1, "out": 1}}],
|
|
89
|
+
}
|
|
90
|
+
body = to_run_task("profile", profile)
|
|
91
|
+
assert body["goal"] == "Extract fields. null when absent."
|
|
92
|
+
assert body["student_system_prompt"] == "You are precise."
|
|
93
|
+
assert body["examples"] == [{"problem": "doc text", "solution": "{}"}]
|
|
94
|
+
ok, detail = push_artifact("http://127.0.0.1:9/none", "s", "profile", {}, timeout=2)
|
|
95
|
+
assert not ok and "unreachable" in detail
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_cmd_push_sweeps_site_artifacts(tmp_path, monkeypatch, capsys):
|
|
99
|
+
repo = tmp_path / "repo"
|
|
100
|
+
repo.mkdir()
|
|
101
|
+
(repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
|
|
102
|
+
monkeypatch.chdir(tmp_path)
|
|
103
|
+
(tmp_path / "extract_receipt.profile.json").write_text(
|
|
104
|
+
'{"goal": {"description": "d", "constraints": []}, "prompt": {"system_prompt": "sp"}, "examples": []}',
|
|
105
|
+
encoding="utf-8")
|
|
106
|
+
(tmp_path / "extract_receipt.jeremy.json").write_text(
|
|
107
|
+
'{"goal": "g", "system_prompt": "sp", "examples": []}', encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
backend = _Backend()
|
|
110
|
+
try:
|
|
111
|
+
rc = main(["push", str(repo), "--endpoint", backend.url])
|
|
112
|
+
finally:
|
|
113
|
+
backend.stop()
|
|
114
|
+
|
|
115
|
+
assert rc == 0
|
|
116
|
+
assert len(backend.received) == 2
|
|
117
|
+
assert all(set(r) == {"goal", "examples", "student_system_prompt", "student_goal"}
|
|
118
|
+
for r in backend.received)
|
|
119
|
+
assert "pushed 2 artifact(s)" in capsys.readouterr().out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_cmd_push_no_artifacts_exits_2(tmp_path, monkeypatch, capsys):
|
|
123
|
+
repo = tmp_path / "repo"
|
|
124
|
+
repo.mkdir()
|
|
125
|
+
(repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
|
|
126
|
+
monkeypatch.chdir(tmp_path)
|
|
127
|
+
|
|
128
|
+
rc = main(["push", str(repo)])
|
|
129
|
+
|
|
130
|
+
assert rc == 2
|
|
131
|
+
assert "nabla build" in capsys.readouterr().err
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_cmd_push_unreachable_backend_exits_1(tmp_path, monkeypatch, capsys):
|
|
135
|
+
monkeypatch.chdir(tmp_path)
|
|
136
|
+
(tmp_path / "x.profile.json").write_text('{"p": 1}', encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
rc = main(["push", "--artifact", "x.profile.json",
|
|
139
|
+
"--endpoint", "http://127.0.0.1:9/none"])
|
|
140
|
+
|
|
141
|
+
assert rc == 1
|
|
142
|
+
captured = capsys.readouterr()
|
|
143
|
+
assert "unreachable" in captured.err
|
|
144
|
+
assert "NABLA_PUSH_ENDPOINT" in captured.out + captured.err
|
nabla_cli-0.8.0/nabla/pusher.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""`nabla push` transport: deliver build/jeremy artifacts to the backend.
|
|
2
|
-
|
|
3
|
-
The endpoint is deliberately swappable — local Next.js dev server today, a
|
|
4
|
-
hosted service later. Resolution order (first hit wins):
|
|
5
|
-
|
|
6
|
-
1. --endpoint flag
|
|
7
|
-
2. NABLA_PUSH_ENDPOINT env var
|
|
8
|
-
3. "push_endpoint" in ~/.nabla/config.json
|
|
9
|
-
4. DEFAULT_ENDPOINT (the local dev server)
|
|
10
|
-
|
|
11
|
-
One POST per artifact:
|
|
12
|
-
|
|
13
|
-
{"site": "<symbol>", "kind": "profile" | "jeremy", "artifact": {...}}
|
|
14
|
-
|
|
15
|
-
A 2xx response is success; anything else (or no listener at all) is
|
|
16
|
-
reported per artifact without aborting the rest of the batch.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import json
|
|
22
|
-
import os
|
|
23
|
-
import urllib.error
|
|
24
|
-
import urllib.request
|
|
25
|
-
|
|
26
|
-
from .config import load_config
|
|
27
|
-
|
|
28
|
-
DEFAULT_ENDPOINT = "http://localhost:3000/api/profiles"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def resolve_endpoint(flag_value: str | None = None) -> str:
|
|
32
|
-
if flag_value:
|
|
33
|
-
return flag_value
|
|
34
|
-
env = os.environ.get("NABLA_PUSH_ENDPOINT")
|
|
35
|
-
if env:
|
|
36
|
-
return env
|
|
37
|
-
stored = load_config().get("push_endpoint")
|
|
38
|
-
if stored:
|
|
39
|
-
return stored
|
|
40
|
-
return DEFAULT_ENDPOINT
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def push_artifact(endpoint: str, site: str, kind: str, artifact: dict,
|
|
44
|
-
timeout: int = 30) -> tuple[bool, str]:
|
|
45
|
-
"""POST one artifact. Returns (ok, detail) — never raises."""
|
|
46
|
-
payload = json.dumps(
|
|
47
|
-
{"site": site, "kind": kind, "artifact": artifact}
|
|
48
|
-
).encode("utf-8")
|
|
49
|
-
req = urllib.request.Request(
|
|
50
|
-
endpoint,
|
|
51
|
-
data=payload,
|
|
52
|
-
method="POST",
|
|
53
|
-
headers={"Content-Type": "application/json", "User-Agent": "nabla-push/0.1"},
|
|
54
|
-
)
|
|
55
|
-
try:
|
|
56
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
57
|
-
body = resp.read().decode("utf-8", "replace")[:200]
|
|
58
|
-
return True, f"HTTP {resp.status}" + (f" · {body}" if body.strip() else "")
|
|
59
|
-
except urllib.error.HTTPError as exc:
|
|
60
|
-
try:
|
|
61
|
-
detail = exc.read().decode("utf-8", "replace")[:200]
|
|
62
|
-
except Exception: # noqa: BLE001 - body read is best-effort
|
|
63
|
-
detail = ""
|
|
64
|
-
return False, f"HTTP {exc.code}" + (f" · {detail}" if detail else f" · {exc.reason}")
|
|
65
|
-
except urllib.error.URLError as exc:
|
|
66
|
-
return False, f"unreachable ({exc.reason})"
|
|
67
|
-
except Exception as exc: # noqa: BLE001 - transport must never crash the CLI
|
|
68
|
-
return False, str(exc)[:200]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|