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.
- onetaskgraph_sdk-0.1.0/.gitignore +33 -0
- onetaskgraph_sdk-0.1.0/PKG-INFO +32 -0
- onetaskgraph_sdk-0.1.0/README.md +20 -0
- onetaskgraph_sdk-0.1.0/generate.py +482 -0
- onetaskgraph_sdk-0.1.0/project.json +79 -0
- onetaskgraph_sdk-0.1.0/pyproject.toml +41 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/__init__.py +9 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/__init__.py +2 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/client.py +293 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/effective_config.py +93 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/global_id.py +17 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/models.py +12 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_plan.py +65 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_edge.py +174 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_label.py +189 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_project.py +230 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_qualified_task.py +234 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/query_response_of_search_hit.py +291 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/source_failure.py +78 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/source_listing.py +153 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/_generated/status_category.py +14 -0
- onetaskgraph_sdk-0.1.0/src/onetaskgraph_sdk/client.py +115 -0
- onetaskgraph_sdk-0.1.0/tests/live/test_live.py +7 -0
- onetaskgraph_sdk-0.1.0/tests/test_artifact.py +73 -0
- onetaskgraph_sdk-0.1.0/tests/test_client.py +294 -0
- onetaskgraph_sdk-0.1.0/tests/test_version.py +9 -0
- 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 }
|