nabla-cli 0.7.2__tar.gz → 0.8.1__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.7.2 → nabla_cli-0.8.1}/PKG-INFO +19 -1
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/README.md +18 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/__init__.py +1 -1
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/cli.py +91 -0
- nabla_cli-0.8.1/nabla/pusher.py +121 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla_cli.egg-info/PKG-INFO +19 -1
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla_cli.egg-info/SOURCES.txt +2 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/pyproject.toml +1 -1
- nabla_cli-0.8.1/tests/test_push.py +144 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/__main__.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/branding.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/config.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/contract.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/induce.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/profile.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/recorder.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/samplegen.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/scanner.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/term.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla/ui.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/setup.cfg +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_cli_ux.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_config.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_induce.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_recorder.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/tests/test_scanner.py +0 -0
- {nabla_cli-0.7.2 → nabla_cli-0.8.1}/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.8.1
|
|
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
|
|
@@ -180,6 +180,24 @@ Same pipeline as `build` (captured pairs in, one goal-inference call) —
|
|
|
180
180
|
only the output shape differs; nothing is added that `build`'s own
|
|
181
181
|
output doesn't contain.
|
|
182
182
|
|
|
183
|
+
### `nabla push`
|
|
184
|
+
|
|
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
|
+
|
|
183
201
|
## Providers
|
|
184
202
|
|
|
185
203
|
`nabla init` supports three providers. Pick whichever you have a key
|
|
@@ -166,6 +166,24 @@ Same pipeline as `build` (captured pairs in, one goal-inference call) —
|
|
|
166
166
|
only the output shape differs; nothing is added that `build`'s own
|
|
167
167
|
output doesn't contain.
|
|
168
168
|
|
|
169
|
+
### `nabla push`
|
|
170
|
+
|
|
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
|
+
|
|
169
187
|
## Providers
|
|
170
188
|
|
|
171
189
|
`nabla init` supports three providers. Pick whichever you have a key
|
|
@@ -156,6 +156,28 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
156
156
|
p_jeremy.add_argument("--out", default=None,
|
|
157
157
|
help="output path (default: <site>.jeremy.json)")
|
|
158
158
|
|
|
159
|
+
p_push = sub.add_parser(
|
|
160
|
+
"push",
|
|
161
|
+
help="send build/jeremy artifacts to the backend dispatcher (seed generation)",
|
|
162
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
163
|
+
epilog=(
|
|
164
|
+
"pushes every <site>.profile.json / <site>.jeremy.json found for the\n"
|
|
165
|
+
"repo's supported sites (or one site with --site, or exact files with\n"
|
|
166
|
+
"--artifact). Endpoint resolution: --endpoint > NABLA_PUSH_ENDPOINT >\n"
|
|
167
|
+
"push_endpoint in ~/.nabla/config.json > local default.\n\n"
|
|
168
|
+
"examples:\n"
|
|
169
|
+
" nabla push\n"
|
|
170
|
+
" nabla push --site extract_invoice\n"
|
|
171
|
+
" nabla push --artifact solve_problem.jeremy.json --endpoint https://api.example.com/profiles\n"
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
p_push.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
|
|
175
|
+
p_push.add_argument("--site", help="only push this site's artifacts")
|
|
176
|
+
p_push.add_argument("--artifact", action="append", default=None,
|
|
177
|
+
help="push this exact artifact file (repeatable; skips site discovery)")
|
|
178
|
+
p_push.add_argument("--endpoint", default=None,
|
|
179
|
+
help="backend URL (default: local dev server; see epilog for resolution order)")
|
|
180
|
+
|
|
159
181
|
return parser
|
|
160
182
|
|
|
161
183
|
|
|
@@ -218,6 +240,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
218
240
|
"build": _cmd_profile,
|
|
219
241
|
"profile": _cmd_profile, # legacy alias
|
|
220
242
|
"jeremy": lambda a: _cmd_profile(a, mode="jeremy"),
|
|
243
|
+
"push": _cmd_push,
|
|
221
244
|
}
|
|
222
245
|
try:
|
|
223
246
|
return commands[args.command](args)
|
|
@@ -934,5 +957,73 @@ def _build_one(args, site, final_rail: bool = False, mode: str = "profile") -> i
|
|
|
934
957
|
return 0
|
|
935
958
|
|
|
936
959
|
|
|
960
|
+
def _cmd_push(args) -> int:
|
|
961
|
+
from .pusher import push_artifact, resolve_endpoint
|
|
962
|
+
from .ui import rail, rail_end, rail_ok, rail_start
|
|
963
|
+
|
|
964
|
+
rail_start(_out, "nabla push")
|
|
965
|
+
endpoint = resolve_endpoint(args.endpoint)
|
|
966
|
+
rail(_out, f"endpoint {endpoint}")
|
|
967
|
+
|
|
968
|
+
# Collect (site_symbol, kind, path) triples.
|
|
969
|
+
targets: list[tuple[str, str, Path]] = []
|
|
970
|
+
if args.artifact:
|
|
971
|
+
for raw in args.artifact:
|
|
972
|
+
path = Path(raw)
|
|
973
|
+
if not path.exists():
|
|
974
|
+
_fail(f"nabla push: {path} not found")
|
|
975
|
+
return 2
|
|
976
|
+
kind = "jeremy" if path.name.endswith(".jeremy.json") else "profile"
|
|
977
|
+
symbol = path.name.split(".")[0]
|
|
978
|
+
targets.append((symbol, kind, path))
|
|
979
|
+
else:
|
|
980
|
+
sites = scan(args.repo)
|
|
981
|
+
supported = [s for s in sites
|
|
982
|
+
if is_supported(resolve_schema(s, args.repo)[1], s.flags)]
|
|
983
|
+
if args.site:
|
|
984
|
+
supported = [s for s in supported if s.symbol == args.site]
|
|
985
|
+
if not supported:
|
|
986
|
+
_fail(f"nabla push: no supported site named {args.site!r}")
|
|
987
|
+
return 2
|
|
988
|
+
for site in supported:
|
|
989
|
+
for kind, name in (("profile", f"{site.symbol}.profile.json"),
|
|
990
|
+
("jeremy", f"{site.symbol}.jeremy.json")):
|
|
991
|
+
path = Path(name)
|
|
992
|
+
if path.exists():
|
|
993
|
+
targets.append((site.symbol, kind, path))
|
|
994
|
+
if not targets:
|
|
995
|
+
print("nabla push: no artifacts found. Run first: nabla build or nabla jeremy",
|
|
996
|
+
file=sys.stderr)
|
|
997
|
+
return 2
|
|
998
|
+
|
|
999
|
+
rail(_out)
|
|
1000
|
+
pushed = failed = 0
|
|
1001
|
+
for symbol, kind, path in targets:
|
|
1002
|
+
try:
|
|
1003
|
+
artifact = json.loads(path.read_text(encoding="utf-8"))
|
|
1004
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1005
|
+
_fail(f"✗ {path}: unreadable ({str(exc)[:120]})")
|
|
1006
|
+
failed += 1
|
|
1007
|
+
continue
|
|
1008
|
+
ok, detail = push_artifact(endpoint, symbol, kind, artifact)
|
|
1009
|
+
if ok:
|
|
1010
|
+
rail_ok(_out, f"{symbol} ({kind}) -> {detail}")
|
|
1011
|
+
pushed += 1
|
|
1012
|
+
else:
|
|
1013
|
+
_fail(f"✗ {symbol} ({kind}): {detail}")
|
|
1014
|
+
failed += 1
|
|
1015
|
+
|
|
1016
|
+
rail(_out)
|
|
1017
|
+
if failed and not pushed:
|
|
1018
|
+
rail_end(_out, f"push failed — is the dispatcher running at {endpoint}? "
|
|
1019
|
+
"start it from backend/ (langgraph dev, then uvicorn "
|
|
1020
|
+
"seed_generator.server:app --port 8000), or override with "
|
|
1021
|
+
"--endpoint / NABLA_PUSH_ENDPOINT", style="red")
|
|
1022
|
+
return 1
|
|
1023
|
+
summary = f"pushed {pushed} artifact(s)" + (f", {failed} failed" if failed else "")
|
|
1024
|
+
rail_end(_out, summary, style="green" if not failed else "yellow")
|
|
1025
|
+
return 0 if not failed else 1
|
|
1026
|
+
|
|
1027
|
+
|
|
937
1028
|
if __name__ == "__main__":
|
|
938
1029
|
sys.exit(main())
|
|
@@ -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.8.1
|
|
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
|
|
@@ -180,6 +180,24 @@ Same pipeline as `build` (captured pairs in, one goal-inference call) —
|
|
|
180
180
|
only the output shape differs; nothing is added that `build`'s own
|
|
181
181
|
output doesn't contain.
|
|
182
182
|
|
|
183
|
+
### `nabla push`
|
|
184
|
+
|
|
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
|
+
|
|
183
201
|
## Providers
|
|
184
202
|
|
|
185
203
|
`nabla init` supports three providers. Pick whichever you have a key
|
|
@@ -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/pusher.py
|
|
12
13
|
nabla/recorder.py
|
|
13
14
|
nabla/samplegen.py
|
|
14
15
|
nabla/scanner.py
|
|
@@ -27,6 +28,7 @@ tests/test_config.py
|
|
|
27
28
|
tests/test_e2e_profile.py
|
|
28
29
|
tests/test_induce.py
|
|
29
30
|
tests/test_prompt_recon.py
|
|
31
|
+
tests/test_push.py
|
|
30
32
|
tests/test_recorder.py
|
|
31
33
|
tests/test_samplegen.py
|
|
32
34
|
tests/test_scanner.py
|
|
@@ -0,0 +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 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
|
|
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
|