onetaskgraph-sdk 0.1.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 (27) hide show
  1. onetaskgraph_sdk-0.1.0/.gitignore +33 -0
  2. onetaskgraph_sdk-0.1.0/PKG-INFO +32 -0
  3. onetaskgraph_sdk-0.1.0/README.md +20 -0
  4. onetaskgraph_sdk-0.1.0/generate.py +482 -0
  5. onetaskgraph_sdk-0.1.0/project.json +79 -0
  6. onetaskgraph_sdk-0.1.0/pyproject.toml +41 -0
  7. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/__init__.py +9 -0
  8. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/__init__.py +2 -0
  9. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/client.py +293 -0
  10. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/effective_config.py +93 -0
  11. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/global_id.py +17 -0
  12. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/models.py +12 -0
  13. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_plan.py +65 -0
  14. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_edge.py +174 -0
  15. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_label.py +189 -0
  16. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_project.py +230 -0
  17. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_task.py +234 -0
  18. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_search_hit.py +291 -0
  19. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/source_failure.py +78 -0
  20. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/source_listing.py +153 -0
  21. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/status_category.py +14 -0
  22. onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/client.py +115 -0
  23. onetaskgraph_sdk-0.1.0/tests/live/test_live.py +7 -0
  24. onetaskgraph_sdk-0.1.0/tests/test_artifact.py +73 -0
  25. onetaskgraph_sdk-0.1.0/tests/test_client.py +294 -0
  26. onetaskgraph_sdk-0.1.0/tests/test_version.py +9 -0
  27. onetaskgraph_sdk-0.1.0/uv.lock +540 -0
@@ -0,0 +1,33 @@
1
+ # Local sync bookkeeping for gh-secrets (per-machine; the manifest itself is
2
+ # tracked)
3
+ .gh-secrets-state.json
4
+
5
+ /target/
6
+ **/*.rs.bk
7
+
8
+ .venv/
9
+ __pycache__/
10
+ *.py[cod]
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+
14
+ node_modules/
15
+ *.tsbuildinfo
16
+
17
+ .nx/
18
+
19
+ /dist/
20
+ **/dist/
21
+ /build/
22
+
23
+ /coverage/
24
+ **/coverage/
25
+ *.profraw
26
+ *.profdata
27
+ lcov.info
28
+ .coverage
29
+ .coverage.*
30
+
31
+ # Composed, never committed: the repo plan and the one-time llmlint buildout tier
32
+ /REPO_PLAN.md
33
+ /llmlint.buildout.yml
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.5
2
+ Name: onetaskgraph-sdk
3
+ Version: 0.1.0
4
+ Summary: The Python SDK for onetaskgraph, generated from the binary's own JSON Schema bundle.
5
+ Project-URL: Repository, https://github.com/nickderobertis/onetaskgraph
6
+ Author: Nick DeRobertis
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: onetaskgraph-cli==0.1.0
10
+ Requires-Dist: pydantic>=2.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # onetaskgraph-sdk
14
+
15
+ The typed Python client for `onetaskgraph`. Its request vocabulary, response models,
16
+ and method surface are generated from the JSON Schema and command help emitted by the
17
+ real binary; the workspace's `just lint` rejects committed output that has drifted.
18
+
19
+ ```python
20
+ import asyncio
21
+ from onetaskgraph_sdk import Client
22
+
23
+ tasks = asyncio.run(Client().task_list(status=["todo"]))
24
+ ```
25
+
26
+ Each async call runs one binary subprocess and validates its JSON response. The executable is
27
+ resolved in this order: the `binary=` constructor argument, the
28
+ `ONETASKGRAPH_SDK_BINARY` environment variable, then the `onetaskgraph` executable supplied
29
+ on `PATH` by the packaged binary distribution. Pass `cwd=` to select the directory from
30
+ which configuration is discovered. Partial query exit status 4 is parsed and returned,
31
+ so callers can inspect each typed `SourceFailure`; other non-zero statuses raise
32
+ `OnetaskgraphError` with the exit code.
@@ -0,0 +1,20 @@
1
+ # onetaskgraph-sdk
2
+
3
+ The typed Python client for `onetaskgraph`. Its request vocabulary, response models,
4
+ and method surface are generated from the JSON Schema and command help emitted by the
5
+ real binary; the workspace's `just lint` rejects committed output that has drifted.
6
+
7
+ ```python
8
+ import asyncio
9
+ from onetaskgraph_sdk import Client
10
+
11
+ tasks = asyncio.run(Client().task_list(status=["todo"]))
12
+ ```
13
+
14
+ Each async call runs one binary subprocess and validates its JSON response. The executable is
15
+ resolved in this order: the `binary=` constructor argument, the
16
+ `ONETASKGRAPH_SDK_BINARY` environment variable, then the `onetaskgraph` executable supplied
17
+ on `PATH` by the packaged binary distribution. Pass `cwd=` to select the directory from
18
+ which configuration is discovered. Partial query exit status 4 is parsed and returned,
19
+ so callers can inspect each typed `SourceFailure`; other non-zero statuses raise
20
+ `OnetaskgraphError` with the exit code.
@@ -0,0 +1,482 @@
1
+ """Generate the Python contract and client surface from the running binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import keyword
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import TypedDict
13
+
14
+ from pydantic import JsonValue, TypeAdapter
15
+
16
+ ROOT = Path(__file__).parent
17
+ GENERATED = ROOT / "src" / "onetaskgraph_sdk" / "_generated"
18
+ RESPONSE_ROOTS = {
19
+ "task_list": "QueryResponseOfQualifiedTask",
20
+ "task_show": "QueryResponseOfQualifiedTask",
21
+ "task_deps": "QueryResponseOfQualifiedEdge",
22
+ "project_list": "QueryResponseOfQualifiedProject",
23
+ "project_show": "QueryResponseOfQualifiedProject",
24
+ "project_deps": "QueryResponseOfQualifiedEdge",
25
+ "label_list": "QueryResponseOfQualifiedLabel",
26
+ "search": "QueryResponseOfSearchHit",
27
+ "sources_list": "SourceListing",
28
+ "config_show": "EffectiveConfig",
29
+ }
30
+ RETURN_TYPES = {"sources_list": "list[SourceListing]"}
31
+ OPTION_TYPES = {
32
+ "allow_partial": "bool",
33
+ "default_sources": "list[str] | tuple[str, ...]",
34
+ "direction": "choices",
35
+ "explain": "bool",
36
+ "in_": "choices",
37
+ "kind": "choices",
38
+ "label": "list[str] | tuple[str, ...]",
39
+ "limit": "int",
40
+ "no_project": "bool",
41
+ "not_label": "list[str] | tuple[str, ...]",
42
+ "page": "str",
43
+ "page_size": "int",
44
+ "project": "str",
45
+ "search": "str",
46
+ "set": "list[str] | tuple[str, ...]",
47
+ "source": "list[str] | tuple[str, ...]",
48
+ "status": "choice_list",
49
+ }
50
+ OPTION_PLACEHOLDERS = {
51
+ "allow_partial": None,
52
+ "default_sources": "NAMES",
53
+ "direction": "DIRECTION",
54
+ "explain": None,
55
+ "in_": "FIELDS",
56
+ "kind": "KIND",
57
+ "label": "L",
58
+ "limit": "N",
59
+ "no_project": None,
60
+ "not_label": "L",
61
+ "page": "TOKEN",
62
+ "page_size": "N",
63
+ "project": "P",
64
+ "search": "TEXT",
65
+ "set": "PATH=VALUE",
66
+ "source": "S",
67
+ "status": "S",
68
+ }
69
+
70
+
71
+ class SchemaBundle(TypedDict):
72
+ """The validated portion of the emitted bundle generation consumes."""
73
+
74
+ roots: dict[str, JsonValue]
75
+
76
+
77
+ def run_workspace_binary(*args: str) -> str:
78
+ """Run the workspace binary, building the exact artifact under generation."""
79
+ result = subprocess.run(
80
+ ["cargo", "run", "--quiet", "-p", "onetaskgraph", "--bin", "onetaskgraph", "--", *args],
81
+ cwd=ROOT.parent.parent,
82
+ check=True,
83
+ text=True,
84
+ capture_output=True,
85
+ )
86
+ return result.stdout
87
+
88
+
89
+ def leaves(prefix: tuple[str, ...] = ()) -> list[tuple[str, ...]]:
90
+ """Discover public command leaves recursively from clap's emitted help."""
91
+ help_text = run_workspace_binary(*prefix, "--help")
92
+ in_commands = False
93
+ commands: list[str] = []
94
+ for line in help_text.splitlines():
95
+ if line == "Commands:":
96
+ in_commands = True
97
+ continue
98
+ if in_commands and line and not line.startswith(" "):
99
+ break
100
+ if in_commands and line.startswith(" "):
101
+ name = line.strip().split()[0]
102
+ if name not in {"help", "schema"}:
103
+ commands.append(name)
104
+ found: list[tuple[str, ...]] = []
105
+ for command in commands:
106
+ child = (*prefix, command)
107
+ child_help = run_workspace_binary(*child, "--help")
108
+ if "Commands:\n" in child_help:
109
+ found.extend(leaves(child))
110
+ else:
111
+ found.append(child)
112
+ return found
113
+
114
+
115
+ def option_names(command: tuple[str, ...]) -> list[str]:
116
+ """Derive keyword names from clap's help for one discovered leaf command."""
117
+ discovered = re.findall(
118
+ r"^\s+--([a-z][a-z-]*)(?: <([^>]+)>)?",
119
+ run_workspace_binary(*command, "--help"),
120
+ re.MULTILINE,
121
+ )
122
+ names = [name for name, _ in discovered]
123
+ normalized = {name.replace("-", "_") for name in names} - {"help", "json", "output"}
124
+ result = sorted(f"{name}_" if keyword.iskeyword(name) else name for name in normalized)
125
+ placeholders = {
126
+ (
127
+ f"{name.replace('-', '_')}_"
128
+ if keyword.iskeyword(name.replace("-", "_"))
129
+ else name.replace("-", "_")
130
+ ): (placeholder or None)
131
+ for name, placeholder in discovered
132
+ if name not in {"help", "json", "output"}
133
+ }
134
+ validate_option_placeholders(placeholders, result)
135
+ return result
136
+
137
+
138
+ def validate_option_placeholders(placeholders: dict[str, str | None], names: list[str]) -> None:
139
+ """Reject help whose option value shapes drifted from generated typing."""
140
+ for name in names:
141
+ if placeholders[name] != OPTION_PLACEHOLDERS[name]:
142
+ raise SystemExit(
143
+ f"binary changed the value shape for option --{name.replace('_', '-')}"
144
+ )
145
+
146
+
147
+ def option_type(command: tuple[str, ...], name: str) -> str:
148
+ """Derive finite option domains from clap help and scalar shapes from placeholders."""
149
+ configured = OPTION_TYPES[name]
150
+ if configured not in {"choices", "choice_list"}:
151
+ return configured
152
+ cli_name = name.removesuffix("_").replace("_", "-")
153
+ help_text = run_workspace_binary(*command, "--help")
154
+ choices = choice_values(help_text, cli_name)
155
+ literal = "Literal[" + ", ".join(repr(choice) for choice in choices) + "]"
156
+ return f"list[{literal}] | tuple[{literal}, ...]" if configured == "choice_list" else literal
157
+
158
+
159
+ def choice_values(help_text: str, cli_name: str) -> list[str]:
160
+ """Read one finite option vocabulary from clap's emitted help."""
161
+ _, separator, block = help_text.partition(f"--{cli_name} ")
162
+ if not separator:
163
+ raise SystemExit(f"binary did not report option --{cli_name} in command help")
164
+ block = re.split(r"\n\s+(?:--|-h, --)", block, maxsplit=1)[0]
165
+ choices = re.findall(r"^\s+- ([a-z0-9-]+):", block, re.MULTILINE)
166
+ if not choices:
167
+ raise SystemExit(f"binary did not report possible values for option --{cli_name}")
168
+ return choices
169
+
170
+
171
+ def documented_minimum(schema: JsonValue, field: str) -> int:
172
+ """Derive a numeric lower bound from the binary's schema description."""
173
+ description = schema.get("description") if isinstance(schema, dict) else None
174
+ match = re.search(r"At least (\d+)", description) if isinstance(description, str) else None
175
+ if match is None:
176
+ raise SystemExit(f"{field} schema did not document its minimum")
177
+ return int(match.group(1))
178
+
179
+
180
+ def generate_models(bundle: SchemaBundle, destination: Path) -> None:
181
+ """Generate Pydantic models directly from every response schema in the bundle."""
182
+ destination.mkdir(parents=True, exist_ok=True)
183
+ exports: list[str] = []
184
+ for root in sorted(
185
+ set(RESPONSE_ROOTS.values()) | {"SourceFailure", "QueryPlan", "GlobalId", "StatusCategory"}
186
+ ):
187
+ schema = bundle["roots"][root]
188
+ add_variant_titles(schema, root)
189
+ rename_qualified_definitions(schema)
190
+ if root == "SourceListing":
191
+ definitions = schema.get("$defs") if isinstance(schema, dict) else None
192
+ capabilities = (
193
+ definitions.get("Capabilities") if isinstance(definitions, dict) else None
194
+ )
195
+ properties = capabilities.get("properties") if isinstance(capabilities, dict) else None
196
+ page_size = properties.get("max_page_size") if isinstance(properties, dict) else None
197
+ minimum = documented_minimum(page_size, "SourceListing.max_page_size")
198
+ assert isinstance(page_size, dict)
199
+ page_size["minimum"] = minimum
200
+ module = camel_to_snake(root)
201
+ source = destination / f"{module}.py"
202
+ with tempfile.TemporaryDirectory() as temporary:
203
+ schema_path = Path(temporary) / f"{module}.json"
204
+ schema_path.write_text(json.dumps(schema), encoding="utf-8")
205
+ subprocess.run(
206
+ [
207
+ "datamodel-codegen",
208
+ "--input",
209
+ str(schema_path),
210
+ "--input-file-type",
211
+ "jsonschema",
212
+ "--output",
213
+ str(source),
214
+ "--output-model-type",
215
+ "pydantic_v2.BaseModel",
216
+ "--target-python-version",
217
+ "3.14",
218
+ "--use-standard-collections",
219
+ "--use-union-operator",
220
+ "--use-annotated",
221
+ "--use-title-as-name",
222
+ "--disable-timestamp",
223
+ ],
224
+ check=True,
225
+ )
226
+ generated = source.read_text(encoding="utf-8").splitlines()
227
+ if len(generated) > 1 and generated[1].startswith("# filename:"):
228
+ generated[1] = f"# schema root: {root}"
229
+ if root == "EffectiveConfig":
230
+ generated = [
231
+ line.replace(
232
+ " value: Annotated[Any",
233
+ " # A setting is arbitrary JSON by the emitted wire contract.\n"
234
+ " value: Annotated[Any",
235
+ )
236
+ for line in generated
237
+ ]
238
+ source.write_text(
239
+ "# ruff: noqa: E501 # Generated descriptions preserve the schema's text.\n"
240
+ + "\n".join(generated)
241
+ + "\n",
242
+ encoding="utf-8",
243
+ )
244
+ generated_name = "QueryResponse" if root.startswith("QueryResponseOf") else root
245
+ exports.append(f"from .{module} import {generated_name} as {root}")
246
+ (destination / "models.py").write_text(
247
+ "# ruff: noqa: F401, I001 # Generated public re-exports are used by consumers.\n"
248
+ + "\n".join(exports)
249
+ + "\n",
250
+ )
251
+
252
+
253
+ def add_variant_titles(value: JsonValue, hint: str) -> None:
254
+ """Give anonymous schema variants stable domain names before model generation."""
255
+ match value:
256
+ case list(items):
257
+ for item in items:
258
+ add_variant_titles(item, hint)
259
+ return
260
+ case dict(mapping):
261
+ value = mapping
262
+ case _:
263
+ return
264
+ variants = value.get("oneOf")
265
+ if isinstance(variants, list):
266
+ for variant in variants:
267
+ if not isinstance(variant, dict) or "title" in variant:
268
+ continue
269
+ discriminant = variant.get("const")
270
+ properties = variant.get("properties")
271
+ if discriminant is None and isinstance(properties, dict):
272
+ for property_schema in properties.values():
273
+ if isinstance(property_schema, dict) and "const" in property_schema:
274
+ discriminant = property_schema["const"]
275
+ break
276
+ if isinstance(discriminant, str):
277
+ words = "".join(part.title() for part in discriminant.split("-"))
278
+ variant["title"] = f"{hint}{words}"
279
+ for key, child in value.items():
280
+ bare = key.removeprefix("$")
281
+ child_hint = bare if any(char.isupper() for char in bare) else bare.title()
282
+ add_variant_titles(child, child_hint or hint)
283
+
284
+
285
+ def rename_qualified_definitions(value: JsonValue) -> None:
286
+ """Name generic qualified definitions after the task or project they contain."""
287
+ if not isinstance(value, dict):
288
+ return
289
+ definitions = value.get("$defs")
290
+ if not isinstance(definitions, dict):
291
+ return
292
+ renames: dict[str, str] = {}
293
+ for name, definition in definitions.items():
294
+ if not name.startswith("Qualified") or not isinstance(definition, dict):
295
+ continue
296
+ properties = definition.get("properties")
297
+ item = properties.get("item") if isinstance(properties, dict) else None
298
+ reference = item.get("$ref") if isinstance(item, dict) else None
299
+ if isinstance(reference, str):
300
+ renames[name] = f"Qualified{reference.rsplit('/', 1)[-1]}"
301
+ for old, new in renames.items():
302
+ definitions[new] = definitions.pop(old)
303
+ replace_references(value, renames)
304
+
305
+
306
+ def replace_references(value: JsonValue, renames: dict[str, str]) -> None:
307
+ """Update local references after a generated-definition rename."""
308
+ match value:
309
+ case list(items):
310
+ for item in items:
311
+ replace_references(item, renames)
312
+ case dict(mapping):
313
+ reference = mapping.get("$ref")
314
+ if isinstance(reference, str):
315
+ tail = reference.rsplit("/", 1)[-1]
316
+ if tail in renames:
317
+ mapping["$ref"] = reference.rsplit("/", 1)[0] + "/" + renames[tail]
318
+ for child in mapping.values():
319
+ replace_references(child, renames)
320
+ case _:
321
+ return
322
+
323
+
324
+ def camel_to_snake(value: str) -> str:
325
+ """Convert a schema root name into a stable module name."""
326
+ chars: list[str] = []
327
+ for index, char in enumerate(value):
328
+ if char.isupper() and index and not value[index - 1].isupper():
329
+ chars.append("_")
330
+ chars.append(char.lower())
331
+ return "".join(chars)
332
+
333
+
334
+ def generate_client(commands: list[tuple[str, ...]], destination: Path) -> None:
335
+ """Generate one typed method per discovered public command."""
336
+ names = {"_".join(command): command for command in commands}
337
+ missing = sorted(set(names) - set(RESPONSE_ROOTS))
338
+ if missing:
339
+ raise SystemExit(
340
+ "client has no method for command: "
341
+ + ", ".join(name.replace("_", " ") for name in missing)
342
+ )
343
+ lines = [
344
+ '"""Generated typed client methods. Do not edit."""',
345
+ "from __future__ import annotations",
346
+ "",
347
+ "from typing import Literal",
348
+ "",
349
+ "from .models import (",
350
+ *[
351
+ f" {root},"
352
+ for root in sorted(set(RESPONSE_ROOTS.values()) | {"GlobalId", "StatusCategory"})
353
+ ],
354
+ ")",
355
+ "",
356
+ "class GeneratedClient:",
357
+ ' """Methods generated from the binary command surface."""',
358
+ "",
359
+ " async def _invoke[T](",
360
+ " self, command: list[str], model: object, **options: object",
361
+ " ) -> T:",
362
+ " raise NotImplementedError",
363
+ "",
364
+ ]
365
+ for name, command in sorted(names.items()):
366
+ root = RESPONSE_ROOTS[name]
367
+ return_type = RETURN_TYPES.get(name, root)
368
+ match command:
369
+ case ("search",):
370
+ positional = "text"
371
+ case ("task" | "project", "show" | "deps"):
372
+ positional = "id"
373
+ case _:
374
+ positional = None
375
+ keywords = [item for item in option_names(command) if item != positional]
376
+ positional_type = "GlobalId | str" if positional == "id" else "str"
377
+ parameters = (
378
+ ([f"{positional}: {positional_type}"] if positional else [])
379
+ + ["*"]
380
+ + [f"{item}: {option_type(command, item)} | None = None" for item in keywords]
381
+ )
382
+ if parameters[-1] == "*":
383
+ parameters.pop()
384
+ passed = [f"{item}={item}" for item in ([positional] if positional else []) + keywords]
385
+ lines.extend(
386
+ [
387
+ f" async def {name}(self, {', '.join(parameters)}) -> {return_type}:",
388
+ f' """Run ``onetaskgraph {" ".join(command)}``."""',
389
+ " return await self._invoke("
390
+ f"{list(command)!r}, {return_type}, {', '.join(passed)})",
391
+ "",
392
+ ]
393
+ )
394
+ (destination / "client.py").write_text("\n".join(lines), encoding="utf-8")
395
+ (destination / "__init__.py").write_text(
396
+ "from .client import GeneratedClient as GeneratedClient\n"
397
+ "from .models import * # noqa: F403 # Schema roots define the public set.\n",
398
+ encoding="utf-8",
399
+ )
400
+
401
+
402
+ def format_generated(destination: Path) -> None:
403
+ """Apply the package's locked formatter to deterministic generated output."""
404
+ subprocess.run(["ruff", "format", str(destination)], check=True, capture_output=True)
405
+ subprocess.run(
406
+ ["ruff", "check", "--fix", "--select", "F401", str(destination)],
407
+ check=True,
408
+ )
409
+ subprocess.run(["ruff", "format", str(destination)], check=True, capture_output=True)
410
+
411
+
412
+ def check_generated(expected_dir: Path, actual_dir: Path) -> None:
413
+ """Reject a generated directory that differs from the expected output."""
414
+ expected = {
415
+ path.name: path.read_text(encoding="utf-8")
416
+ for path in expected_dir.iterdir()
417
+ if path.is_file()
418
+ }
419
+ actual = {
420
+ path.name: path.read_text(encoding="utf-8")
421
+ for path in actual_dir.iterdir()
422
+ if path.is_file()
423
+ }
424
+ changed = sorted(
425
+ set(expected) ^ set(actual)
426
+ | {name for name in expected.keys() & actual.keys() if expected[name] != actual[name]}
427
+ )
428
+ if changed:
429
+ raise SystemExit(
430
+ "generated Python SDK is stale: "
431
+ + ", ".join(changed)
432
+ + "; run `uv run python generate.py` from sdks/python to regenerate"
433
+ )
434
+
435
+
436
+ def validate_schema_bundle(parsed: JsonValue) -> SchemaBundle:
437
+ """Validate the binary's schema output before generation consumes a root."""
438
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("roots"), dict):
439
+ raise SystemExit("binary emitted an invalid schema bundle: expected an object with roots")
440
+ bundle = TypeAdapter(SchemaBundle).validate_python(parsed)
441
+ required = set(RESPONSE_ROOTS.values()) | {
442
+ "SourceFailure",
443
+ "QueryPlan",
444
+ "GlobalId",
445
+ "StatusCategory",
446
+ }
447
+ missing = sorted(required - bundle["roots"].keys())
448
+ malformed = sorted(
449
+ name
450
+ for name in required & bundle["roots"].keys()
451
+ if not isinstance(bundle["roots"][name], dict)
452
+ )
453
+ if missing or malformed:
454
+ details = [f"missing roots: {', '.join(missing)}"] if missing else []
455
+ details += [f"non-object roots: {', '.join(malformed)}"] if malformed else []
456
+ raise SystemExit("binary emitted an invalid schema bundle: " + "; ".join(details))
457
+ return bundle
458
+
459
+
460
+ def generate(bundle: SchemaBundle, *, check: bool, destination: Path = GENERATED) -> None:
461
+ """Write or check generated output for one validated bundle."""
462
+ commands = leaves()
463
+ with tempfile.TemporaryDirectory() as temporary:
464
+ target = Path(temporary) if check else destination
465
+ generate_models(bundle, target)
466
+ generate_client(commands, target)
467
+ format_generated(target)
468
+ if check:
469
+ check_generated(target, destination)
470
+
471
+
472
+ def main() -> None:
473
+ """Regenerate, or compare regeneration with the committed package."""
474
+ parser = argparse.ArgumentParser()
475
+ parser.add_argument("--check", action="store_true")
476
+ args = parser.parse_args()
477
+ parsed = json.loads(run_workspace_binary("schema"))
478
+ generate(validate_schema_bundle(parsed), check=args.check)
479
+
480
+
481
+ if __name__ == "__main__":
482
+ main()
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
3
+ "name": "sdk-python",
4
+ "projectType": "library",
5
+ "tags": ["lang:python", "layer:sdk"],
6
+ "implicitDependencies": ["onetaskgraph"],
7
+ "targets": {
8
+ "bootstrap": {
9
+ "executor": "nx:run-commands",
10
+ "options": {
11
+ "command": "uv sync --frozen",
12
+ "cwd": "sdks/python"
13
+ },
14
+ "//": "Uncached: it reaches PyPI and writes a .venv outside the cacheable output set."
15
+ },
16
+ "check": {
17
+ "executor": "nx:noop",
18
+ "dependsOn": ["format-check", "lint", "typecheck", "test", "coverage"]
19
+ },
20
+ "format": {
21
+ "executor": "nx:run-commands",
22
+ "options": {
23
+ "command": "uv run --frozen ruff format .",
24
+ "cwd": "sdks/python"
25
+ },
26
+ "cache": false,
27
+ "//": "Uncached: it rewrites the tree in place."
28
+ },
29
+ "format-check": {
30
+ "executor": "nx:run-commands",
31
+ "options": {
32
+ "command": "uv run --frozen ruff format --check .",
33
+ "cwd": "sdks/python"
34
+ }
35
+ },
36
+ "lint": {
37
+ "executor": "nx:run-commands",
38
+ "options": {
39
+ "command": "uv run --frozen ruff check .",
40
+ "cwd": "sdks/python"
41
+ }
42
+ },
43
+ "generate-check": {
44
+ "executor": "nx:run-commands",
45
+ "options": {
46
+ "command": "uv run --frozen python generate.py --check",
47
+ "cwd": "sdks/python"
48
+ }
49
+ },
50
+ "typecheck": {
51
+ "executor": "nx:run-commands",
52
+ "options": {
53
+ "command": "uv run --frozen ty check",
54
+ "cwd": "sdks/python"
55
+ }
56
+ },
57
+ "test": {
58
+ "executor": "nx:run-commands",
59
+ "options": {
60
+ "command": "uv run --frozen pytest -q --no-cov",
61
+ "cwd": "sdks/python"
62
+ }
63
+ },
64
+ "coverage": {
65
+ "executor": "nx:run-commands",
66
+ "options": {
67
+ "command": "uv run --frozen pytest -q",
68
+ "cwd": "sdks/python"
69
+ }
70
+ },
71
+ "test-live": {
72
+ "executor": "nx:run-commands",
73
+ "options": {
74
+ "command": "bash scripts/pytest-live.sh sdks/python"
75
+ },
76
+ "//": "Uncached: a live test reaches a third-party API and reads a credential from the environment, neither of which is an input in this tree."
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "onetaskgraph-sdk"
3
+ version = "0.1.0"
4
+ description = "The Python SDK for onetaskgraph, generated from the binary's own JSON Schema bundle."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ authors = [{ name = "Nick DeRobertis" }]
9
+ dependencies = ["onetaskgraph-cli==0.1.0", "pydantic>=2.12"]
10
+
11
+ [project.urls]
12
+ Repository = "https://github.com/nickderobertis/onetaskgraph"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/onetaskgraph_sdk"]
20
+
21
+ [dependency-groups]
22
+ dev = ["datamodel-code-generator>=0.33", "pytest>=9.0", "pytest-cov>=7.1", "ruff>=0.16.4", "ty>=0.0.74"]
23
+
24
+ [tool.ruff]
25
+ line-length = 100
26
+
27
+ [tool.ruff.lint]
28
+ select = ["E", "F", "I", "UP", "B", "SIM", "ANN", "D"]
29
+
30
+ [tool.ruff.lint.pydocstyle]
31
+ convention = "google"
32
+
33
+ [tool.pytest.ini_options]
34
+ addopts = "--cov=onetaskgraph_sdk --cov-report=term-missing --cov-fail-under=95"
35
+ testpaths = ["tests"]
36
+
37
+ [tool.ty.environment]
38
+ python-version = "3.14"
39
+
40
+ [tool.uv.sources]
41
+ onetaskgraph-cli = { workspace = true }