nabla-cli 0.7.2__tar.gz → 0.8.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.
Files changed (37) hide show
  1. {nabla_cli-0.7.2/nabla_cli.egg-info → nabla_cli-0.8.0}/PKG-INFO +12 -1
  2. nabla_cli-0.7.2/PKG-INFO → nabla_cli-0.8.0/README.md +11 -14
  3. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/__init__.py +1 -1
  4. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/cli.py +89 -0
  5. nabla_cli-0.8.0/nabla/pusher.py +68 -0
  6. nabla_cli-0.7.2/README.md → nabla_cli-0.8.0/nabla_cli.egg-info/PKG-INFO +25 -0
  7. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla_cli.egg-info/SOURCES.txt +2 -0
  8. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/pyproject.toml +1 -1
  9. nabla_cli-0.8.0/tests/test_push.py +119 -0
  10. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/__main__.py +0 -0
  11. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/branding.py +0 -0
  12. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/config.py +0 -0
  13. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/contract.py +0 -0
  14. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/induce.py +0 -0
  15. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/profile.py +0 -0
  16. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/prompt_recon.py +0 -0
  17. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/recorder.py +0 -0
  18. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/samplegen.py +0 -0
  19. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/scanner.py +0 -0
  20. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/schema_resolver.py +0 -0
  21. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/schemas/site_profile.schema.json +0 -0
  22. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/term.py +0 -0
  23. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla/ui.py +0 -0
  24. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  25. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  26. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla_cli.egg-info/requires.txt +0 -0
  27. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/nabla_cli.egg-info/top_level.txt +0 -0
  28. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/setup.cfg +0 -0
  29. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_cli_ux.py +0 -0
  30. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_config.py +0 -0
  31. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_e2e_profile.py +0 -0
  32. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_induce.py +0 -0
  33. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_prompt_recon.py +0 -0
  34. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_recorder.py +0 -0
  35. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_samplegen.py +0 -0
  36. {nabla_cli-0.7.2 → nabla_cli-0.8.0}/tests/test_scanner.py +0 -0
  37. {nabla_cli-0.7.2 → nabla_cli-0.8.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.7.2
3
+ Version: 0.8.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
@@ -180,6 +180,17 @@ 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
+ Sends built artifacts to the nabla backend: every `<site>.profile.json`
186
+ and `<site>.jeremy.json` found for the repo's supported sites (narrow
187
+ with `--site`, or push exact files with `--artifact`). Each artifact is
188
+ POSTed as `{"site", "kind", "artifact"}`.
189
+
190
+ The endpoint defaults to the local dev server and is meant to change
191
+ later — resolution order: `--endpoint` flag, then `NABLA_PUSH_ENDPOINT`,
192
+ then `push_endpoint` in `~/.nabla/config.json`, then the default.
193
+
183
194
  ## Providers
184
195
 
185
196
  `nabla init` supports three providers. Pick whichever you have a key
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: nabla-cli
3
- Version: 0.7.2
4
- Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
- License: MIT
6
- Requires-Python: >=3.11
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: jsonschema
9
- Requires-Dist: pydantic>=2
10
- Requires-Dist: openai>=1.40
11
- Requires-Dist: rich>=13.0
12
- Provides-Extra: dev
13
- Requires-Dist: pytest; extra == "dev"
14
-
15
1
  # nabla — OpenAI call-site harvester
16
2
 
17
3
  `nabla` finds the OpenAI call sites in a Python repo, records real
@@ -180,6 +166,17 @@ Same pipeline as `build` (captured pairs in, one goal-inference call) —
180
166
  only the output shape differs; nothing is added that `build`'s own
181
167
  output doesn't contain.
182
168
 
169
+ ### `nabla push`
170
+
171
+ Sends built artifacts to the nabla backend: every `<site>.profile.json`
172
+ and `<site>.jeremy.json` found for the repo's supported sites (narrow
173
+ with `--site`, or push exact files with `--artifact`). Each artifact is
174
+ POSTed as `{"site", "kind", "artifact"}`.
175
+
176
+ The endpoint defaults to the local dev server and is meant to change
177
+ later — resolution order: `--endpoint` flag, then `NABLA_PUSH_ENDPOINT`,
178
+ then `push_endpoint` in `~/.nabla/config.json`, then the default.
179
+
183
180
  ## Providers
184
181
 
185
182
  `nabla init` supports three providers. Pick whichever you have a key
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.8.0"
@@ -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 nabla backend",
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,71 @@ 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 backend running at {endpoint}? "
1019
+ "override with --endpoint or NABLA_PUSH_ENDPOINT", style="red")
1020
+ return 1
1021
+ summary = f"pushed {pushed} artifact(s)" + (f", {failed} failed" if failed else "")
1022
+ rail_end(_out, summary, style="green" if not failed else "yellow")
1023
+ return 0 if not failed else 1
1024
+
1025
+
937
1026
  if __name__ == "__main__":
938
1027
  sys.exit(main())
@@ -0,0 +1,68 @@
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]
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: nabla-cli
3
+ Version: 0.8.0
4
+ Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: jsonschema
9
+ Requires-Dist: pydantic>=2
10
+ Requires-Dist: openai>=1.40
11
+ Requires-Dist: rich>=13.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+
1
15
  # nabla — OpenAI call-site harvester
2
16
 
3
17
  `nabla` finds the OpenAI call sites in a Python repo, records real
@@ -166,6 +180,17 @@ Same pipeline as `build` (captured pairs in, one goal-inference call) —
166
180
  only the output shape differs; nothing is added that `build`'s own
167
181
  output doesn't contain.
168
182
 
183
+ ### `nabla push`
184
+
185
+ Sends built artifacts to the nabla backend: every `<site>.profile.json`
186
+ and `<site>.jeremy.json` found for the repo's supported sites (narrow
187
+ with `--site`, or push exact files with `--artifact`). Each artifact is
188
+ POSTed as `{"site", "kind", "artifact"}`.
189
+
190
+ The endpoint defaults to the local dev server and is meant to change
191
+ later — resolution order: `--endpoint` flag, then `NABLA_PUSH_ENDPOINT`,
192
+ then `push_endpoint` in `~/.nabla/config.json`, then the default.
193
+
169
194
  ## Providers
170
195
 
171
196
  `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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.7.2"
3
+ version = "0.8.0"
4
4
  description = "Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,119 @@
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_success_and_unreachable():
66
+ backend = _Backend()
67
+ try:
68
+ ok, detail = push_artifact(backend.url, "s", "profile", {"a": 1})
69
+ assert ok and "200" in detail
70
+ assert backend.received == [{"site": "s", "kind": "profile", "artifact": {"a": 1}}]
71
+ finally:
72
+ backend.stop()
73
+ ok, detail = push_artifact("http://127.0.0.1:9/none", "s", "profile", {}, timeout=2)
74
+ assert not ok and "unreachable" in detail
75
+
76
+
77
+ def test_cmd_push_sweeps_site_artifacts(tmp_path, monkeypatch, capsys):
78
+ repo = tmp_path / "repo"
79
+ repo.mkdir()
80
+ (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
81
+ monkeypatch.chdir(tmp_path)
82
+ (tmp_path / "extract_receipt.profile.json").write_text('{"p": 1}', encoding="utf-8")
83
+ (tmp_path / "extract_receipt.jeremy.json").write_text('{"j": 1}', encoding="utf-8")
84
+
85
+ backend = _Backend()
86
+ try:
87
+ rc = main(["push", str(repo), "--endpoint", backend.url])
88
+ finally:
89
+ backend.stop()
90
+
91
+ assert rc == 0
92
+ kinds = {(r["site"], r["kind"]) for r in backend.received}
93
+ assert kinds == {("extract_receipt", "profile"), ("extract_receipt", "jeremy")}
94
+ assert "pushed 2 artifact(s)" in capsys.readouterr().out
95
+
96
+
97
+ def test_cmd_push_no_artifacts_exits_2(tmp_path, monkeypatch, capsys):
98
+ repo = tmp_path / "repo"
99
+ repo.mkdir()
100
+ (repo / "single_call_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
101
+ monkeypatch.chdir(tmp_path)
102
+
103
+ rc = main(["push", str(repo)])
104
+
105
+ assert rc == 2
106
+ assert "nabla build" in capsys.readouterr().err
107
+
108
+
109
+ def test_cmd_push_unreachable_backend_exits_1(tmp_path, monkeypatch, capsys):
110
+ monkeypatch.chdir(tmp_path)
111
+ (tmp_path / "x.profile.json").write_text('{"p": 1}', encoding="utf-8")
112
+
113
+ rc = main(["push", "--artifact", "x.profile.json",
114
+ "--endpoint", "http://127.0.0.1:9/none"])
115
+
116
+ assert rc == 1
117
+ captured = capsys.readouterr()
118
+ assert "unreachable" in captured.err
119
+ 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