nabla-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,309 @@
1
+ """T3 — output-schema resolution + verifiability classing. See cli-plan.md T3.
2
+
3
+ Re-parses source from raw_site.file/span; never imports scanner or
4
+ prompt_recon. The only dynamic import this module performs is of the
5
+ target repo's own module, and only to resolve a Pydantic model's
6
+ `.model_json_schema()` for the `.parse(response_format=Model)` shape —
7
+ that import is fully guarded and degrades to (None, PYDANTIC_PARSE).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import importlib
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from .contract import RawSite, RawSiteRef, Verifiability
19
+
20
+
21
+ def resolve_schema(raw_site: RawSite, repo_path: str) -> tuple[dict[str, Any] | None, Verifiability]:
22
+ """Normalize the site's output declaration to one JSON Schema.
23
+
24
+ Handles: response_format json_schema (extract + inline $defs/$refs),
25
+ Pydantic model in .parse() (guarded dynamic import -> model_json_schema()),
26
+ tools function schemas, json_object (None, JSON_OBJECT_NO_SCHEMA),
27
+ nothing (None, FREE_TEXT), and wrapper-indirect sites (resolve the
28
+ caller's own schema argument back to a module constant).
29
+ """
30
+ repo = Path(repo_path)
31
+
32
+ if raw_site.wrapper is not None:
33
+ return _resolve_wrapper_site(raw_site, repo)
34
+
35
+ tree = _parse_file(repo, raw_site.file)
36
+ if tree is None:
37
+ return None, Verifiability.FREE_TEXT
38
+
39
+ call_node = _find_call_by_span(tree, raw_site.span, predicate=_is_openai_call)
40
+ if call_node is None:
41
+ return None, Verifiability.FREE_TEXT
42
+
43
+ module_consts = _module_level_assignments(tree)
44
+
45
+ tools_node = _get_kw(call_node, "tools")
46
+ if tools_node is not None:
47
+ return _resolve_tools(tools_node, module_consts)
48
+
49
+ rf_node = _get_kw(call_node, "response_format")
50
+ if rf_node is not None:
51
+ if isinstance(rf_node, ast.Dict):
52
+ try:
53
+ value = _eval_node(rf_node, module_consts)
54
+ except ValueError:
55
+ value = None
56
+ if isinstance(value, dict):
57
+ rf_type = value.get("type")
58
+ if rf_type == "json_schema":
59
+ schema = (value.get("json_schema") or {}).get("schema")
60
+ if isinstance(schema, dict):
61
+ return _normalize_schema(schema), Verifiability.JSON_SCHEMA
62
+ return None, Verifiability.JSON_SCHEMA
63
+ if rf_type == "json_object":
64
+ return None, Verifiability.JSON_OBJECT_NO_SCHEMA
65
+ else:
66
+ # A Name/Attribute referencing a Pydantic model class, e.g.
67
+ # response_format=Order.
68
+ schema = _resolve_pydantic_model(rf_node, raw_site, repo)
69
+ return schema, Verifiability.PYDANTIC_PARSE
70
+
71
+ return None, Verifiability.FREE_TEXT
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Wrapper-indirect sites: schema arrives as an argument to the wrapper call.
76
+ # --------------------------------------------------------------------------
77
+
78
+ def _resolve_wrapper_site(raw_site: RawSite, repo: Path) -> tuple[dict[str, Any] | None, Verifiability]:
79
+ tree = _parse_file(repo, raw_site.file)
80
+ if tree is None:
81
+ return None, Verifiability.FREE_TEXT
82
+
83
+ call_node = _find_call_by_span(tree, raw_site.span, predicate=None)
84
+ if call_node is None:
85
+ return None, Verifiability.FREE_TEXT
86
+
87
+ module_consts = _module_level_assignments(tree)
88
+
89
+ schema_node = _get_kw(call_node, "schema")
90
+ if schema_node is None and call_node.args:
91
+ schema_node = call_node.args[-1]
92
+ if schema_node is None:
93
+ return None, Verifiability.JSON_SCHEMA
94
+
95
+ try:
96
+ value = _eval_node(schema_node, module_consts)
97
+ except ValueError:
98
+ return None, Verifiability.JSON_SCHEMA
99
+
100
+ if isinstance(value, dict):
101
+ return _normalize_schema(value), Verifiability.JSON_SCHEMA
102
+ return None, Verifiability.JSON_SCHEMA
103
+
104
+
105
+ # --------------------------------------------------------------------------
106
+ # AST helpers
107
+ # --------------------------------------------------------------------------
108
+
109
+ def _parse_file(repo: Path, rel_file: str) -> ast.Module | None:
110
+ path = repo / rel_file
111
+ try:
112
+ source = path.read_text(encoding="utf-8")
113
+ except OSError:
114
+ return None
115
+ try:
116
+ return ast.parse(source, filename=str(path))
117
+ except SyntaxError:
118
+ return None
119
+
120
+
121
+ def _is_openai_call(node: ast.Call) -> bool:
122
+ return isinstance(node.func, ast.Attribute) and node.func.attr in ("create", "parse")
123
+
124
+
125
+ def _find_call_by_span(
126
+ tree: ast.AST, span: tuple[int, int], predicate: Callable[[ast.Call], bool] | None
127
+ ) -> ast.Call | None:
128
+ start, end = span
129
+ matches = [
130
+ node
131
+ for node in ast.walk(tree)
132
+ if isinstance(node, ast.Call)
133
+ and node.lineno == start
134
+ and getattr(node, "end_lineno", node.lineno) == end
135
+ ]
136
+ if predicate is not None:
137
+ filtered = [n for n in matches if predicate(n)]
138
+ if filtered:
139
+ matches = filtered
140
+ return matches[0] if matches else None
141
+
142
+
143
+ def _get_kw(call: ast.Call, name: str) -> ast.AST | None:
144
+ for kw in call.keywords:
145
+ if kw.arg == name:
146
+ return kw.value
147
+ return None
148
+
149
+
150
+ def _module_level_assignments(tree: ast.Module) -> dict[str, ast.AST]:
151
+ consts: dict[str, ast.AST] = {}
152
+ for node in tree.body:
153
+ if isinstance(node, ast.Assign):
154
+ for target in node.targets:
155
+ if isinstance(target, ast.Name):
156
+ consts[target.id] = node.value
157
+ elif isinstance(node, ast.AnnAssign) and node.value is not None and isinstance(node.target, ast.Name):
158
+ consts[node.target.id] = node.value
159
+ return consts
160
+
161
+
162
+ def _eval_node(node: ast.AST, consts: dict[str, ast.AST], _stack: frozenset[str] = frozenset()) -> Any:
163
+ """Restricted static evaluator: literals, dict/list/tuple literals, and
164
+ Name references resolved against module-level constants. Never executes
165
+ arbitrary code."""
166
+ if isinstance(node, ast.Constant):
167
+ return node.value
168
+ if isinstance(node, ast.Dict):
169
+ result: dict[Any, Any] = {}
170
+ for k, v in zip(node.keys, node.values):
171
+ if k is None: # dict unpacking (**x) — unsupported, bail
172
+ raise ValueError("dict unpacking not supported")
173
+ result[_eval_node(k, consts, _stack)] = _eval_node(v, consts, _stack)
174
+ return result
175
+ if isinstance(node, (ast.List, ast.Tuple)):
176
+ return [_eval_node(elt, consts, _stack) for elt in node.elts]
177
+ if isinstance(node, ast.Name):
178
+ if node.id in _stack:
179
+ raise ValueError(f"circular reference resolving {node.id!r}")
180
+ if node.id not in consts:
181
+ raise ValueError(f"unresolved name {node.id!r}")
182
+ return _eval_node(consts[node.id], consts, _stack | {node.id})
183
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
184
+ return -_eval_node(node.operand, consts, _stack)
185
+ raise ValueError(f"cannot statically evaluate {ast.dump(node)}")
186
+
187
+
188
+ # --------------------------------------------------------------------------
189
+ # tools=[...] resolution
190
+ # --------------------------------------------------------------------------
191
+
192
+ def _resolve_tools(
193
+ tools_node: ast.AST, consts: dict[str, ast.AST]
194
+ ) -> tuple[dict[str, Any] | None, Verifiability]:
195
+ try:
196
+ tools = _eval_node(tools_node, consts)
197
+ except ValueError:
198
+ return None, Verifiability.TOOL_CALL
199
+ if not isinstance(tools, list) or not tools:
200
+ return None, Verifiability.TOOL_CALL
201
+
202
+ if len(tools) == 1:
203
+ params = _tool_parameters(tools[0])
204
+ if isinstance(params, dict):
205
+ return _normalize_schema(params), Verifiability.TOOL_CALL
206
+ return None, Verifiability.TOOL_CALL
207
+
208
+ schemas: dict[str, Any] = {}
209
+ for tool in tools:
210
+ fn = tool.get("function", {}) if isinstance(tool, dict) else {}
211
+ name = fn.get("name")
212
+ params = fn.get("parameters")
213
+ if name and isinstance(params, dict):
214
+ schemas[name] = _normalize_schema(params)
215
+ return (schemas or None), Verifiability.TOOL_CALL
216
+
217
+
218
+ def _tool_parameters(tool: Any) -> dict[str, Any] | None:
219
+ if not isinstance(tool, dict):
220
+ return None
221
+ fn = tool.get("function", {})
222
+ if not isinstance(fn, dict):
223
+ return None
224
+ params = fn.get("parameters")
225
+ return params if isinstance(params, dict) else None
226
+
227
+
228
+ # --------------------------------------------------------------------------
229
+ # Pydantic .parse(response_format=Model) resolution — guarded dynamic import
230
+ # --------------------------------------------------------------------------
231
+
232
+ def _resolve_pydantic_model(node: ast.AST, raw_site: RawSite, repo: Path) -> dict[str, Any] | None:
233
+ class_name: str | None = None
234
+ if isinstance(node, ast.Name):
235
+ class_name = node.id
236
+ elif isinstance(node, ast.Attribute):
237
+ class_name = node.attr
238
+
239
+ if class_name is None:
240
+ return None
241
+
242
+ module_dotted = _dotted_module(raw_site.file)
243
+ if module_dotted is None:
244
+ return None
245
+
246
+ repo_str = str(repo.resolve())
247
+ added = False
248
+ try:
249
+ if repo_str not in sys.path:
250
+ sys.path.insert(0, repo_str)
251
+ added = True
252
+ try:
253
+ module = importlib.import_module(module_dotted)
254
+ model_cls = getattr(module, class_name)
255
+ schema = model_cls.model_json_schema()
256
+ finally:
257
+ if added:
258
+ try:
259
+ sys.path.remove(repo_str)
260
+ except ValueError:
261
+ pass
262
+ except Exception:
263
+ return None
264
+
265
+ if not isinstance(schema, dict):
266
+ return None
267
+ return _normalize_schema(schema)
268
+
269
+
270
+ def _dotted_module(rel_file: str) -> str | None:
271
+ p = Path(rel_file)
272
+ if p.suffix != ".py":
273
+ return None
274
+ parts = list(p.with_suffix("").parts)
275
+ if parts and parts[-1] == "__init__":
276
+ parts = parts[:-1]
277
+ return ".".join(parts) if parts else None
278
+
279
+
280
+ # --------------------------------------------------------------------------
281
+ # Schema normalization: inline $defs/$refs so the schema is self-contained.
282
+ # --------------------------------------------------------------------------
283
+
284
+ def _normalize_schema(schema: dict[str, Any]) -> dict[str, Any]:
285
+ defs = schema.get("$defs", {}) if isinstance(schema.get("$defs"), dict) else {}
286
+
287
+ def _inline(node: Any, seen: frozenset[str]) -> Any:
288
+ if isinstance(node, dict):
289
+ if "$ref" in node and isinstance(node["$ref"], str) and node["$ref"].startswith("#/$defs/"):
290
+ ref_name = node["$ref"].split("/")[-1]
291
+ if ref_name in seen or ref_name not in defs:
292
+ # Cycle or dangling ref: leave as-is rather than recurse forever.
293
+ return {k: v for k, v in node.items()}
294
+ resolved = _inline(defs[ref_name], seen | {ref_name})
295
+ merged = dict(resolved) if isinstance(resolved, dict) else {}
296
+ for k, v in node.items():
297
+ if k != "$ref":
298
+ merged[k] = _inline(v, seen)
299
+ return merged
300
+ return {k: _inline(v, seen) for k, v in node.items() if k != "$defs"}
301
+ if isinstance(node, list):
302
+ return [_inline(item, seen) for item in node]
303
+ return node
304
+
305
+ result = _inline(schema, frozenset())
306
+ if isinstance(result, dict):
307
+ result.pop("$defs", None)
308
+ return result
309
+ return schema
@@ -0,0 +1,136 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "nabla/site_profile/0.1",
4
+ "title": "Nabla site profile — the Phase 0 handoff artifact",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["profile_version", "site_id", "source", "classification", "prompt",
8
+ "output_schema", "sampling", "examples", "goal", "baseline", "provenance"],
9
+ "properties": {
10
+ "profile_version": { "const": "0.1" },
11
+ "site_id": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
12
+ "source": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["file", "span", "symbol"],
16
+ "properties": {
17
+ "file": { "type": "string" },
18
+ "span": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 },
19
+ "symbol": { "type": "string" }
20
+ }
21
+ },
22
+ "classification": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["supported", "verifiability", "flags"],
26
+ "properties": {
27
+ "supported": { "type": "boolean" },
28
+ "verifiability": { "enum": ["json_schema", "pydantic_parse", "tool_call", "json_object_no_schema", "free_text"] },
29
+ "flags": {
30
+ "type": "array",
31
+ "items": { "enum": ["streaming", "async", "multi_turn", "vision", "n_gt_1", "wrapper_indirect"] }
32
+ }
33
+ }
34
+ },
35
+ "prompt": {
36
+ "type": "object",
37
+ "additionalProperties": false,
38
+ "required": ["template", "slots"],
39
+ "properties": {
40
+ "template": { "type": "string" },
41
+ "partial": { "type": "boolean" },
42
+ "slots": {
43
+ "type": "array",
44
+ "items": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": ["name"],
48
+ "properties": {
49
+ "name": { "type": "string" },
50
+ "observed_values": { "type": "array", "items": { "type": "string" } },
51
+ "inferred_type": { "type": "string" }
52
+ }
53
+ }
54
+ },
55
+ "system_prompt": { "type": ["string", "null"] },
56
+ "embedded_few_shot": {
57
+ "type": "array",
58
+ "items": { "type": "object" }
59
+ }
60
+ }
61
+ },
62
+ "output_schema": { "type": ["object", "null"] },
63
+ "sampling": {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "required": ["model"],
67
+ "properties": {
68
+ "model": { "type": "string" },
69
+ "temperature": { "type": ["number", "null"] },
70
+ "max_tokens": { "type": ["integer", "null"] },
71
+ "top_p": { "type": ["number", "null"] },
72
+ "stop": { "type": ["array", "string", "null"] }
73
+ }
74
+ },
75
+ "examples": {
76
+ "type": "array",
77
+ "items": {
78
+ "type": "object",
79
+ "additionalProperties": false,
80
+ "required": ["input_slots", "prompt", "completion", "tokens", "latency_ms"],
81
+ "properties": {
82
+ "input_slots": { "type": "object" },
83
+ "prompt": { "type": "string" },
84
+ "completion": { "type": "string" },
85
+ "tokens": {
86
+ "type": "object",
87
+ "additionalProperties": false,
88
+ "required": ["in", "out"],
89
+ "properties": { "in": { "type": "integer" }, "out": { "type": "integer" } }
90
+ },
91
+ "latency_ms": { "type": "integer" }
92
+ }
93
+ }
94
+ },
95
+ "goal": {
96
+ "type": "object",
97
+ "additionalProperties": false,
98
+ "required": ["description", "constraints", "difficulty_dimensions"],
99
+ "properties": {
100
+ "description": { "type": "string", "minLength": 1 },
101
+ "constraints": { "type": "array", "items": { "type": "string" } },
102
+ "difficulty_dimensions": {
103
+ "type": "array",
104
+ "items": {
105
+ "type": "object",
106
+ "additionalProperties": false,
107
+ "required": ["name", "levels", "evidence"],
108
+ "properties": {
109
+ "name": { "type": "string" },
110
+ "levels": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
111
+ "evidence": { "type": "string" }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ },
117
+ "baseline": {
118
+ "type": "object",
119
+ "additionalProperties": false,
120
+ "required": ["cost_per_call_usd", "p50_latency_ms", "calls_observed"],
121
+ "properties": {
122
+ "cost_per_call_usd": { "type": "number", "minimum": 0 },
123
+ "p50_latency_ms": { "type": "integer", "minimum": 0 },
124
+ "calls_observed": { "type": "integer", "minimum": 0 }
125
+ }
126
+ },
127
+ "provenance": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": ["recorded_from"],
131
+ "properties": {
132
+ "recorded_from": { "enum": ["proxy", "samples", "logs", "none"] }
133
+ }
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: nabla-cli
3
+ Version: 0.1.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
+ Provides-Extra: dev
12
+ Requires-Dist: pytest; extra == "dev"
13
+
14
+ # nabla — OpenAI call-site harvester
15
+
16
+ `nabla` finds the OpenAI call sites in a Python repo, records real
17
+ (prompt, completion) traffic for one of them, and emits a single
18
+ self-contained **site profile** (`site_profile.json`) describing that
19
+ call site: prompt template + slots, resolved output JSON Schema,
20
+ sampling parameters, recorded examples, an inferred goal with difficulty
21
+ dimensions, and a cost/latency baseline.
22
+
23
+ The profile is the handoff artifact for training a small, cheap
24
+ replacement model for that one call site — everything downstream codes
25
+ against the profile, never against your repo.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install nabla-cli
31
+ # or, isolated:
32
+ pipx install nabla-cli
33
+ ```
34
+
35
+ Requires Python ≥ 3.11.
36
+
37
+ ## Quickstart
38
+
39
+ ```bash
40
+ # 1. Find call sites (pure static analysis — no network, no API key)
41
+ nabla scan path/to/repo
42
+
43
+ # 2. Record real traffic for one site, driven by sample inputs
44
+ # (JSONL lines of {"input_slots": {...}} matching the site's function kwargs)
45
+ nabla record path/to/repo --site my_function --samples samples.jsonl --out recorded.jsonl
46
+
47
+ # 3. Assemble + validate the profile (one LLM call for goal inference)
48
+ nabla profile path/to/repo --site my_function --examples recorded.jsonl --out site_profile.json
49
+ ```
50
+
51
+ ## What gets detected
52
+
53
+ `scan` classifies every `chat.completions.create` / `beta...parse` call
54
+ by verifiability: `json_schema` and `pydantic_parse` sites are supported
55
+ harvest targets; `tool_call`, `json_object_no_schema`, and `free_text`
56
+ sites are detected and reported but not harvested. Streaming,
57
+ multi-turn, vision, and `n>1` sites are flagged unsupported. Generic
58
+ wrapper functions are expanded to their callers; functions that own
59
+ their prompt template are kept as the site.
60
+
61
+ ## Environment variables
62
+
63
+ | Variable | Purpose |
64
+ |---|---|
65
+ | `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
66
+ | `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
67
+ | `NABLA_UPSTREAM_API_KEY` | Key for the stand-in oracle. |
68
+ | `NABLA_UPSTREAM_MODEL` | Rewrite the model for the stand-in only (the target repo's own model still lands in the profile). |
69
+ | `NABLA_RECORD_DELAY_MS` | Pacing between samples for rate-limited upstreams (default 0). |
70
+ | `NABLA_SAMPLE_TIMEOUT` | Per-sample subprocess timeout in seconds (default 300). |
71
+ | `GEMINI_API_KEY` | Key for the default goal-inference provider (Gemini free tier). |
72
+ | `NABLA_INDUCE_BASE_URL` / `NABLA_INDUCE_API_KEY_ENV` / `NABLA_INDUCE_MODEL` | Swap the goal-inference provider/model. |
73
+
74
+ ## Notes
75
+
76
+ - `record` runs the site's enclosing function in a subprocess per sample,
77
+ with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
78
+ target's tests mock the SDK, the proxy path reports zero traffic
79
+ explicitly instead of writing an empty profile.
80
+ - Recorded prompts/completions are real data from your repo and leave
81
+ the machine only via the goal-inference call. Review before sharing
82
+ profiles.
@@ -0,0 +1,15 @@
1
+ nabla/__init__.py,sha256=9PXtjQRdKH4vIYDFe6pVoZDFWt_b826GKWZcZhF463U,78
2
+ nabla/cli.py,sha256=oD-brSXwHKlSGoZaMrzFPYUVxjEC8eI6Rr2jrALyzYY,4829
3
+ nabla/contract.py,sha256=2BV0aF1PRG607uVX93snIkGUjUG_VBPWzObHWV7Gt6Q,4785
4
+ nabla/induce.py,sha256=OHrxNPeMmOjR_FIEu9_f-FT2KSPMSLb4qHkzXAx8GMY,7692
5
+ nabla/profile.py,sha256=QLoRqGUHdLyOTNPMCGOo7X8UtVCW1LPPDwC8VgrQg2U,5379
6
+ nabla/prompt_recon.py,sha256=ikFsUoyImJw3K8OVdX9VkKfs5qRE85pEZ9io8pKgsuw,20442
7
+ nabla/recorder.py,sha256=Y9zy9MnwYRUhghrIl5UdYavITmL8d6BGivwyEurhXAw,24746
8
+ nabla/scanner.py,sha256=kqk-3UePafLxKdMWYB2VeYZMUrog0Vexh-OwHd8Zw-Y,16956
9
+ nabla/schema_resolver.py,sha256=6IBHi1sTm8oa-4pYtEPiAR9PDWYW_muCJqth_Gy7ynI,11509
10
+ nabla/schemas/site_profile.schema.json,sha256=1nhZty8GcJePhFFR45JzzbCX9ZEJ33ZPwBSWt391Q6c,4727
11
+ nabla_cli-0.1.0.dist-info/METADATA,sha256=UX1jYnRBOboWfHtdNj8Z2jQuQRfFmiGos30ve-fEw-8,3396
12
+ nabla_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ nabla_cli-0.1.0.dist-info/entry_points.txt,sha256=I48El-2J50j-3S97L11RWrgdfOLhwRAv_NWSDD1h2IA,41
14
+ nabla_cli-0.1.0.dist-info/top_level.txt,sha256=dhw4W9HA8wn3H0DYFF4Pb9S2DPbnqFF1cS7DdHcIdhs,6
15
+ nabla_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nabla = nabla.cli:main
@@ -0,0 +1 @@
1
+ nabla