tangle-cli 0.0.1a1__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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
"""Generate the checked-in static Tangle API client pieces from OpenAPI.
|
|
2
|
+
|
|
3
|
+
Run from the repository root with:
|
|
4
|
+
|
|
5
|
+
uv run python -m tangle_cli.openapi.codegen
|
|
6
|
+
|
|
7
|
+
The generator intentionally reuses :mod:`tangle_cli.api_schema` for operation
|
|
8
|
+
normalization so the offline client keeps the dynamic CLI/client expansion
|
|
9
|
+
semantics without requiring OpenAPI parsing at normal runtime.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import copy
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
import keyword
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .parser import (
|
|
32
|
+
DEFAULT_OPENAPI_PATH,
|
|
33
|
+
DEFAULT_OPENAPI_RESOURCE_NAME,
|
|
34
|
+
DEFAULT_OPENAPI_RESOURCE_PACKAGE,
|
|
35
|
+
load_openapi_schema,
|
|
36
|
+
parsed_operations,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
40
|
+
_GENERATED_DIR = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" / "generated"
|
|
41
|
+
DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle"
|
|
42
|
+
DEFAULT_OPERATIONS_CLASS_NAME = "GeneratedTangleApiOperations"
|
|
43
|
+
DEFAULT_MODEL_EXTENSION_MODULE = "tangle_cli.generated_model_extensions"
|
|
44
|
+
DEFAULT_MODEL_ALIASES: dict[str, tuple[str, ...]] = {
|
|
45
|
+
"ComponentSpec": (
|
|
46
|
+
"ComponentSpec-Output",
|
|
47
|
+
"ComponentSpecOutput",
|
|
48
|
+
"ComponentSpec-Input",
|
|
49
|
+
"ComponentSpecInput",
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _safe_identifier(name: str) -> str:
|
|
55
|
+
value = re.sub(r"\W", "_", name).strip("_").lower()
|
|
56
|
+
value = re.sub(r"_+", "_", value) or "value"
|
|
57
|
+
if value[0].isdigit():
|
|
58
|
+
value = f"value_{value}"
|
|
59
|
+
if keyword.iskeyword(value):
|
|
60
|
+
value = f"{value}_"
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _class_name(name: str) -> str:
|
|
65
|
+
parts = re.split(r"[^0-9A-Za-z]+", name)
|
|
66
|
+
value = "".join(part[:1].upper() + part[1:] for part in parts if part)
|
|
67
|
+
if not value:
|
|
68
|
+
value = "GeneratedModel"
|
|
69
|
+
if value[0].isdigit():
|
|
70
|
+
value = f"Model{value}"
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _schema_ref_name(
|
|
75
|
+
schema: dict[str, Any] | None,
|
|
76
|
+
model_ref_aliases: dict[str, str] | None = None,
|
|
77
|
+
) -> str | None:
|
|
78
|
+
if not schema:
|
|
79
|
+
return None
|
|
80
|
+
ref = schema.get("$ref")
|
|
81
|
+
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
|
|
82
|
+
schema_name = ref.rsplit("/", 1)[1]
|
|
83
|
+
return model_ref_aliases.get(schema_name, _class_name(schema_name)) if model_ref_aliases else _class_name(schema_name)
|
|
84
|
+
for key in ("anyOf", "oneOf", "allOf"):
|
|
85
|
+
for child in schema.get(key, []) or []:
|
|
86
|
+
name = _schema_ref_name(child, model_ref_aliases=model_ref_aliases)
|
|
87
|
+
if name:
|
|
88
|
+
return name
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _success_response(operation: dict[str, Any]) -> dict[str, Any] | None:
|
|
93
|
+
responses = operation.get("responses", {}) or {}
|
|
94
|
+
for status in ("200", "201", "202", "204", "default"):
|
|
95
|
+
response = responses.get(status)
|
|
96
|
+
if response:
|
|
97
|
+
break
|
|
98
|
+
else:
|
|
99
|
+
response = next(iter(responses.values()), None)
|
|
100
|
+
return response if isinstance(response, dict) else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _success_schema(operation: dict[str, Any]) -> dict[str, Any] | None:
|
|
104
|
+
response = _success_response(operation)
|
|
105
|
+
if response is None:
|
|
106
|
+
return None
|
|
107
|
+
content = response.get("content", {}) or {}
|
|
108
|
+
json_content = content.get("application/json") or next(iter(content.values()), {})
|
|
109
|
+
schema = json_content.get("schema") if isinstance(json_content, dict) else None
|
|
110
|
+
return schema if isinstance(schema, dict) else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _schema_type(schema: dict[str, Any]) -> str | None:
|
|
114
|
+
schema_type = schema.get("type")
|
|
115
|
+
if isinstance(schema_type, str):
|
|
116
|
+
return schema_type
|
|
117
|
+
if isinstance(schema_type, list):
|
|
118
|
+
for item in schema_type:
|
|
119
|
+
if item != "null":
|
|
120
|
+
return str(item)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _schema_allows_null(schema: dict[str, Any] | None) -> bool:
|
|
125
|
+
if not schema:
|
|
126
|
+
return False
|
|
127
|
+
if schema.get("nullable") is True or schema.get("type") == "null":
|
|
128
|
+
return True
|
|
129
|
+
schema_type = schema.get("type")
|
|
130
|
+
if isinstance(schema_type, list) and "null" in schema_type:
|
|
131
|
+
return True
|
|
132
|
+
for key in ("anyOf", "oneOf"):
|
|
133
|
+
for child in schema.get(key, []) or []:
|
|
134
|
+
if isinstance(child, dict) and _schema_allows_null(child):
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _response_model_name(
|
|
140
|
+
operation: dict[str, Any],
|
|
141
|
+
model_ref_aliases: dict[str, str] | None = None,
|
|
142
|
+
) -> str | None:
|
|
143
|
+
schema = _success_schema(operation)
|
|
144
|
+
if not schema:
|
|
145
|
+
return None
|
|
146
|
+
if _schema_type(schema) == "array":
|
|
147
|
+
items = schema.get("items")
|
|
148
|
+
return _schema_ref_name(items if isinstance(items, dict) else None, model_ref_aliases=model_ref_aliases)
|
|
149
|
+
return _schema_ref_name(schema, model_ref_aliases=model_ref_aliases)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _response_return_annotation(
|
|
153
|
+
operation: dict[str, Any],
|
|
154
|
+
model_ref_aliases: dict[str, str] | None = None,
|
|
155
|
+
) -> str:
|
|
156
|
+
response = _success_response(operation)
|
|
157
|
+
if response is None:
|
|
158
|
+
return "Any"
|
|
159
|
+
schema = _success_schema(operation)
|
|
160
|
+
if schema is None or not schema:
|
|
161
|
+
return "None"
|
|
162
|
+
return _schema_return_annotation(schema, model_ref_aliases=model_ref_aliases)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _schema_return_annotation(
|
|
166
|
+
schema: dict[str, Any],
|
|
167
|
+
model_ref_aliases: dict[str, str] | None = None,
|
|
168
|
+
) -> str:
|
|
169
|
+
ref_name = _schema_ref_name(schema, model_ref_aliases=model_ref_aliases)
|
|
170
|
+
if ref_name:
|
|
171
|
+
return f"{ref_name} | None" if _schema_allows_null(schema) else ref_name
|
|
172
|
+
|
|
173
|
+
schema_type = _schema_type(schema)
|
|
174
|
+
if schema_type == "array":
|
|
175
|
+
items = schema.get("items")
|
|
176
|
+
item_ref = _schema_ref_name(items if isinstance(items, dict) else None, model_ref_aliases=model_ref_aliases)
|
|
177
|
+
annotation = f"list[{item_ref}]" if item_ref else "list[Any]"
|
|
178
|
+
return f"{annotation} | None" if _schema_allows_null(schema) else annotation
|
|
179
|
+
|
|
180
|
+
primitives = {
|
|
181
|
+
"string": "str",
|
|
182
|
+
"integer": "int",
|
|
183
|
+
"number": "float",
|
|
184
|
+
"boolean": "bool",
|
|
185
|
+
}
|
|
186
|
+
if schema_type in primitives:
|
|
187
|
+
annotation = primitives[schema_type]
|
|
188
|
+
return f"{annotation} | None" if _schema_allows_null(schema) else annotation
|
|
189
|
+
|
|
190
|
+
if schema_type == "object" or "properties" in schema or "additionalProperties" in schema:
|
|
191
|
+
return "dict[str, Any]"
|
|
192
|
+
|
|
193
|
+
return "Any"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_model_alias(value: str) -> tuple[str, tuple[str, ...]]:
|
|
198
|
+
"""Parse ``PublicModel=SourceModel[,OtherSource]`` alias config."""
|
|
199
|
+
|
|
200
|
+
if "=" not in value:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
"Model aliases must use PublicModel=SourceModel[,OtherSource] syntax"
|
|
203
|
+
)
|
|
204
|
+
alias_name, raw_sources = value.split("=", 1)
|
|
205
|
+
alias_name = _class_name(alias_name.strip())
|
|
206
|
+
_validate_class_name(alias_name)
|
|
207
|
+
sources = tuple(source.strip() for source in raw_sources.split(",") if source.strip())
|
|
208
|
+
if not sources:
|
|
209
|
+
raise ValueError(f"Model alias {alias_name!r} must include at least one source schema")
|
|
210
|
+
return alias_name, sources
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _model_alias_mapping(
|
|
214
|
+
model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None,
|
|
215
|
+
) -> dict[str, tuple[str, ...]]:
|
|
216
|
+
"""Return public model aliases, applying built-in defaults first.
|
|
217
|
+
|
|
218
|
+
A string or sequence uses CLI-style ``PublicModel=SourceModel`` entries.
|
|
219
|
+
An empty-string entry disables the built-in defaults.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
aliases = dict(DEFAULT_MODEL_ALIASES)
|
|
223
|
+
if model_aliases is None:
|
|
224
|
+
return aliases
|
|
225
|
+
if isinstance(model_aliases, dict):
|
|
226
|
+
for alias_name, sources in model_aliases.items():
|
|
227
|
+
parsed_alias = _class_name(alias_name)
|
|
228
|
+
_validate_class_name(parsed_alias)
|
|
229
|
+
source_values = [sources] if isinstance(sources, str) else list(sources)
|
|
230
|
+
source_tuple = tuple(str(source).strip() for source in source_values if str(source).strip())
|
|
231
|
+
if not source_tuple:
|
|
232
|
+
raise ValueError(f"Model alias {parsed_alias!r} must include at least one source schema")
|
|
233
|
+
aliases[parsed_alias] = source_tuple
|
|
234
|
+
return aliases
|
|
235
|
+
|
|
236
|
+
values = [model_aliases] if isinstance(model_aliases, str) else list(model_aliases)
|
|
237
|
+
if "" in values:
|
|
238
|
+
aliases = {}
|
|
239
|
+
values = [value for value in values if value != ""]
|
|
240
|
+
for value in values:
|
|
241
|
+
alias_name, sources = _parse_model_alias(value)
|
|
242
|
+
aliases[alias_name] = sources
|
|
243
|
+
return aliases
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _apply_model_aliases(
|
|
247
|
+
schemas: dict[str, Any],
|
|
248
|
+
model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None,
|
|
249
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
250
|
+
"""Add alias schemas and return source schema -> public class aliases."""
|
|
251
|
+
|
|
252
|
+
output = dict(schemas)
|
|
253
|
+
existing_class_names = {_class_name(schema_name) for schema_name in output}
|
|
254
|
+
model_ref_aliases: dict[str, str] = {}
|
|
255
|
+
for alias_name, sources in _model_alias_mapping(model_aliases).items():
|
|
256
|
+
present_sources = [source for source in sources if source in schemas]
|
|
257
|
+
if not present_sources:
|
|
258
|
+
continue
|
|
259
|
+
if alias_name not in existing_class_names:
|
|
260
|
+
output[alias_name] = dict(schemas[present_sources[0]])
|
|
261
|
+
if isinstance(output[alias_name], dict):
|
|
262
|
+
output[alias_name]["title"] = alias_name
|
|
263
|
+
existing_class_names.add(alias_name)
|
|
264
|
+
if alias_name in existing_class_names:
|
|
265
|
+
for source in present_sources:
|
|
266
|
+
model_ref_aliases[source] = alias_name
|
|
267
|
+
return output, model_ref_aliases
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _request_body_schema_mapping(
|
|
271
|
+
request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None,
|
|
272
|
+
) -> dict[str, dict[str, Any]]:
|
|
273
|
+
"""Parse operation request-body schema overrides keyed by operation id."""
|
|
274
|
+
|
|
275
|
+
if request_body_schemas is None:
|
|
276
|
+
return {}
|
|
277
|
+
if isinstance(request_body_schemas, dict):
|
|
278
|
+
return {key: dict(value) for key, value in request_body_schemas.items()}
|
|
279
|
+
|
|
280
|
+
values = [request_body_schemas] if isinstance(request_body_schemas, str) else list(request_body_schemas)
|
|
281
|
+
overrides: dict[str, dict[str, Any]] = {}
|
|
282
|
+
for value in values:
|
|
283
|
+
if "=" not in value:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
"Request body schema overrides must use OperationId={...json schema...} syntax"
|
|
286
|
+
)
|
|
287
|
+
operation_id, raw_schema = value.split("=", 1)
|
|
288
|
+
operation_id = operation_id.strip()
|
|
289
|
+
if not operation_id:
|
|
290
|
+
raise ValueError("Request body schema override operation id cannot be empty")
|
|
291
|
+
try:
|
|
292
|
+
schema = json.loads(raw_schema)
|
|
293
|
+
except json.JSONDecodeError as exc:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"Request body schema override for {operation_id!r} is not valid JSON: {exc.msg}"
|
|
296
|
+
) from exc
|
|
297
|
+
if not isinstance(schema, dict):
|
|
298
|
+
raise ValueError(f"Request body schema override for {operation_id!r} must be a JSON object")
|
|
299
|
+
overrides[operation_id] = schema
|
|
300
|
+
return overrides
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _request_body_schema_file_mapping(values: Sequence[str] | str | None) -> dict[str, dict[str, Any]]:
|
|
304
|
+
"""Parse operation request-body schema overrides from JSON files."""
|
|
305
|
+
|
|
306
|
+
if values is None:
|
|
307
|
+
return {}
|
|
308
|
+
raw_values = [values] if isinstance(values, str) else list(values)
|
|
309
|
+
overrides: dict[str, dict[str, Any]] = {}
|
|
310
|
+
for value in raw_values:
|
|
311
|
+
if "=" not in value:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
"Request body schema file overrides must use OperationId=path/to/schema.json syntax"
|
|
314
|
+
)
|
|
315
|
+
operation_id, raw_path = value.split("=", 1)
|
|
316
|
+
operation_id = operation_id.strip()
|
|
317
|
+
if not operation_id:
|
|
318
|
+
raise ValueError("Request body schema file override operation id cannot be empty")
|
|
319
|
+
path = Path(raw_path).expanduser()
|
|
320
|
+
try:
|
|
321
|
+
schema = json.loads(path.read_text(encoding="utf-8"))
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
raise ValueError(f"Could not read request body schema file {path}: {exc}") from exc
|
|
324
|
+
except json.JSONDecodeError as exc:
|
|
325
|
+
raise ValueError(f"Request body schema file {path} is not valid JSON: {exc.msg}") from exc
|
|
326
|
+
if not isinstance(schema, dict):
|
|
327
|
+
raise ValueError(f"Request body schema file {path} must contain a JSON object")
|
|
328
|
+
overrides[operation_id] = schema
|
|
329
|
+
return overrides
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _operation_override_keys(operation: Any) -> set[str]:
|
|
333
|
+
"""Return supported keys for request-body schema override matching."""
|
|
334
|
+
|
|
335
|
+
operation_id = operation.operation.get("operationId")
|
|
336
|
+
keys = {_method_name(operation.group_name, operation.command_name), operation.operation_name}
|
|
337
|
+
if isinstance(operation_id, str) and operation_id:
|
|
338
|
+
keys.add(operation_id)
|
|
339
|
+
keys.add(_safe_identifier(operation_id))
|
|
340
|
+
return keys
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _set_json_request_body_schema(operation: dict[str, Any], schema: dict[str, Any]) -> None:
|
|
344
|
+
request_body = operation.setdefault("requestBody", {})
|
|
345
|
+
content = request_body.setdefault("content", {})
|
|
346
|
+
media = content.setdefault("application/json", {})
|
|
347
|
+
media["schema"] = schema
|
|
348
|
+
operation["x-tangle-cli-request-body-schema-override"] = True
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _apply_request_body_schema_overrides(
|
|
352
|
+
schema: dict[str, Any],
|
|
353
|
+
request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None,
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
"""Return schema with configured request-body schema overrides applied."""
|
|
356
|
+
|
|
357
|
+
overrides = _request_body_schema_mapping(request_body_schemas)
|
|
358
|
+
if not overrides:
|
|
359
|
+
return schema
|
|
360
|
+
|
|
361
|
+
output = copy.deepcopy(schema)
|
|
362
|
+
operations = parsed_operations(output)
|
|
363
|
+
remaining = dict(overrides)
|
|
364
|
+
for operation in operations:
|
|
365
|
+
matching_keys = _operation_override_keys(operation)
|
|
366
|
+
for key in list(remaining):
|
|
367
|
+
if key in matching_keys:
|
|
368
|
+
_set_json_request_body_schema(operation.operation, remaining.pop(key))
|
|
369
|
+
if remaining:
|
|
370
|
+
raise ValueError(
|
|
371
|
+
"Unknown request body schema override operation(s): " + ", ".join(sorted(remaining))
|
|
372
|
+
)
|
|
373
|
+
return output
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True)
|
|
377
|
+
class _ModelExtensionRef:
|
|
378
|
+
"""Import reference for one generated model extension class."""
|
|
379
|
+
|
|
380
|
+
module_name: str
|
|
381
|
+
class_name: str
|
|
382
|
+
alias: str
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _model_extension_modules(
|
|
386
|
+
model_extension_module: str | Sequence[str] | None,
|
|
387
|
+
) -> list[str]:
|
|
388
|
+
"""Return ordered model extension modules, applying defaults first.
|
|
389
|
+
|
|
390
|
+
``None`` means the built-in default module. A string or sequence appends
|
|
391
|
+
downstream modules after the built-in default. The empty-string sentinel
|
|
392
|
+
disables the default module and is otherwise ignored.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
if model_extension_module is None:
|
|
396
|
+
modules: list[str] = []
|
|
397
|
+
elif isinstance(model_extension_module, str):
|
|
398
|
+
modules = [model_extension_module]
|
|
399
|
+
else:
|
|
400
|
+
modules = list(model_extension_module)
|
|
401
|
+
|
|
402
|
+
include_default = True
|
|
403
|
+
if "" in modules:
|
|
404
|
+
include_default = False
|
|
405
|
+
modules = [module for module in modules if module != ""]
|
|
406
|
+
|
|
407
|
+
ordered = ([DEFAULT_MODEL_EXTENSION_MODULE] if include_default else []) + modules
|
|
408
|
+
deduped: list[str] = []
|
|
409
|
+
seen: set[str] = set()
|
|
410
|
+
for module in ordered:
|
|
411
|
+
if not module or module in seen:
|
|
412
|
+
continue
|
|
413
|
+
seen.add(module)
|
|
414
|
+
deduped.append(module)
|
|
415
|
+
return deduped
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _validate_module_name(module_name: str) -> str:
|
|
419
|
+
parts = module_name.split(".")
|
|
420
|
+
if not parts or any(not re.fullmatch(r"[A-Za-z_]\w*", part) or keyword.iskeyword(part) for part in parts):
|
|
421
|
+
raise ValueError(f"Invalid model extension module name: {module_name!r}")
|
|
422
|
+
return module_name
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _model_extension_mapping(module_name: str) -> dict[str, str]:
|
|
426
|
+
"""Load and validate a MODEL_EXTENSIONS mapping from an extension module."""
|
|
427
|
+
|
|
428
|
+
module_name = _validate_module_name(module_name)
|
|
429
|
+
try:
|
|
430
|
+
module = importlib.import_module(module_name)
|
|
431
|
+
except Exception as exc: # pragma: no cover - importlib preserves details
|
|
432
|
+
raise ValueError(f"Could not import model extension module {module_name!r}: {exc}") from exc
|
|
433
|
+
|
|
434
|
+
mapping = getattr(module, "MODEL_EXTENSIONS", None)
|
|
435
|
+
if not isinstance(mapping, dict):
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f"Model extension module {module_name!r} must define a MODEL_EXTENSIONS dict"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
extensions: dict[str, str] = {}
|
|
441
|
+
for model_name, extension_name in mapping.items():
|
|
442
|
+
if not isinstance(model_name, str) or not isinstance(extension_name, str):
|
|
443
|
+
raise ValueError("MODEL_EXTENSIONS keys and values must be strings")
|
|
444
|
+
_validate_class_name(model_name)
|
|
445
|
+
_validate_class_name(extension_name)
|
|
446
|
+
if not hasattr(module, extension_name):
|
|
447
|
+
raise ValueError(
|
|
448
|
+
f"Model extension module {module_name!r} does not define {extension_name!r}"
|
|
449
|
+
)
|
|
450
|
+
extensions[model_name] = extension_name
|
|
451
|
+
return extensions
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _model_extension_refs(
|
|
455
|
+
model_extension_module: str | Sequence[str] | None,
|
|
456
|
+
) -> dict[str, list[_ModelExtensionRef]]:
|
|
457
|
+
"""Resolve model extension refs by generated class in configured order."""
|
|
458
|
+
|
|
459
|
+
refs_by_model: dict[str, list[_ModelExtensionRef]] = {}
|
|
460
|
+
raw_refs: list[_ModelExtensionRef] = []
|
|
461
|
+
for module_name in _model_extension_modules(model_extension_module):
|
|
462
|
+
for model_name, extension_name in _model_extension_mapping(module_name).items():
|
|
463
|
+
ref = _ModelExtensionRef(
|
|
464
|
+
module_name=module_name,
|
|
465
|
+
class_name=extension_name,
|
|
466
|
+
alias=extension_name,
|
|
467
|
+
)
|
|
468
|
+
refs_by_model.setdefault(model_name, []).append(ref)
|
|
469
|
+
raw_refs.append(ref)
|
|
470
|
+
|
|
471
|
+
unique_ref_keys: list[tuple[str, str]] = []
|
|
472
|
+
seen_ref_keys: set[tuple[str, str]] = set()
|
|
473
|
+
for ref in raw_refs:
|
|
474
|
+
key = (ref.module_name, ref.class_name)
|
|
475
|
+
if key in seen_ref_keys:
|
|
476
|
+
continue
|
|
477
|
+
seen_ref_keys.add(key)
|
|
478
|
+
unique_ref_keys.append(key)
|
|
479
|
+
|
|
480
|
+
class_name_counts = Counter(class_name for _, class_name in unique_ref_keys)
|
|
481
|
+
alias_counts: Counter[str] = Counter()
|
|
482
|
+
aliases_by_ref: dict[tuple[str, str], str] = {}
|
|
483
|
+
for module_name, class_name in unique_ref_keys:
|
|
484
|
+
if class_name_counts[class_name] == 1:
|
|
485
|
+
alias = class_name
|
|
486
|
+
else:
|
|
487
|
+
alias_base = f"_{_safe_identifier(module_name)}_{class_name}"
|
|
488
|
+
alias_counts[alias_base] += 1
|
|
489
|
+
alias = alias_base if alias_counts[alias_base] == 1 else f"{alias_base}_{alias_counts[alias_base]}"
|
|
490
|
+
aliases_by_ref[(module_name, class_name)] = alias
|
|
491
|
+
|
|
492
|
+
aliased: dict[str, list[_ModelExtensionRef]] = {}
|
|
493
|
+
for model_name, refs in refs_by_model.items():
|
|
494
|
+
aliased[model_name] = []
|
|
495
|
+
for ref in refs:
|
|
496
|
+
aliased[model_name].append(
|
|
497
|
+
_ModelExtensionRef(
|
|
498
|
+
module_name=ref.module_name,
|
|
499
|
+
class_name=ref.class_name,
|
|
500
|
+
alias=aliases_by_ref[(ref.module_name, ref.class_name)],
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
return aliased
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _model_extension_import_lines(refs_by_model: dict[str, list[_ModelExtensionRef]]) -> list[str]:
|
|
507
|
+
"""Render deterministic import lines for configured model extensions."""
|
|
508
|
+
|
|
509
|
+
refs_by_module: dict[str, list[_ModelExtensionRef]] = {}
|
|
510
|
+
for refs in refs_by_model.values():
|
|
511
|
+
for ref in refs:
|
|
512
|
+
refs_by_module.setdefault(ref.module_name, []).append(ref)
|
|
513
|
+
|
|
514
|
+
lines: list[str] = []
|
|
515
|
+
for module_name, refs in sorted(refs_by_module.items()):
|
|
516
|
+
imports: list[str] = []
|
|
517
|
+
seen: set[tuple[str, str]] = set()
|
|
518
|
+
for ref in sorted(refs, key=lambda item: (item.class_name, item.alias)):
|
|
519
|
+
key = (ref.class_name, ref.alias)
|
|
520
|
+
if key in seen:
|
|
521
|
+
continue
|
|
522
|
+
seen.add(key)
|
|
523
|
+
if ref.alias == ref.class_name:
|
|
524
|
+
imports.append(ref.class_name)
|
|
525
|
+
else:
|
|
526
|
+
imports.append(f"{ref.class_name} as {ref.alias}")
|
|
527
|
+
lines.append(f"from {module_name} import {', '.join(imports)}")
|
|
528
|
+
return lines
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def generate_models(
|
|
532
|
+
schema: dict[str, Any],
|
|
533
|
+
model_extension_module: str | Sequence[str] | None = None,
|
|
534
|
+
model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None,
|
|
535
|
+
) -> str:
|
|
536
|
+
"""Generate Pydantic model classes and apply configured model extensions."""
|
|
537
|
+
|
|
538
|
+
raw_schemas = schema.get("components", {}).get("schemas", {}) or {}
|
|
539
|
+
schemas, _ = _apply_model_aliases(raw_schemas, model_aliases)
|
|
540
|
+
extension_refs = _model_extension_refs(model_extension_module)
|
|
541
|
+
lines: list[str] = [
|
|
542
|
+
'"""Generated Pydantic models for the checked-in Tangle OpenAPI schema.\n\nDo not edit by hand; run ``uv run python -m tangle_cli.openapi.codegen``.\n"""',
|
|
543
|
+
"",
|
|
544
|
+
"from __future__ import annotations",
|
|
545
|
+
"",
|
|
546
|
+
"from typing import Any",
|
|
547
|
+
"",
|
|
548
|
+
"from pydantic import Field",
|
|
549
|
+
"",
|
|
550
|
+
"from tangle_cli.generated_runtime import TangleGeneratedModel",
|
|
551
|
+
"",
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
generated_class_names = {
|
|
555
|
+
_class_name(schema_name)
|
|
556
|
+
for schema_name, schema_def in schemas.items()
|
|
557
|
+
if isinstance(schema_def, dict)
|
|
558
|
+
and (schema_def.get("type") in {"object", None} or "properties" in schema_def)
|
|
559
|
+
}
|
|
560
|
+
used_extensions = {
|
|
561
|
+
class_name: extension_refs[class_name]
|
|
562
|
+
for class_name in sorted(generated_class_names)
|
|
563
|
+
if class_name in extension_refs
|
|
564
|
+
}
|
|
565
|
+
imports = _model_extension_import_lines(used_extensions)
|
|
566
|
+
if imports:
|
|
567
|
+
lines.extend(imports)
|
|
568
|
+
lines.append("")
|
|
569
|
+
|
|
570
|
+
exports: list[str] = []
|
|
571
|
+
for schema_name, schema_def in sorted(schemas.items(), key=lambda item: _class_name(item[0])):
|
|
572
|
+
class_name = _class_name(schema_name)
|
|
573
|
+
exports.append(class_name)
|
|
574
|
+
if not isinstance(schema_def, dict) or schema_def.get("type") not in {"object", None} and "properties" not in schema_def:
|
|
575
|
+
lines.extend([f"{class_name} = Any", ""])
|
|
576
|
+
continue
|
|
577
|
+
properties = schema_def.get("properties") or {}
|
|
578
|
+
extension_refs_for_class = used_extensions.get(class_name, [])
|
|
579
|
+
generated_base_name = f"_{class_name}Generated"
|
|
580
|
+
lines.extend([f"class {generated_base_name}(TangleGeneratedModel):"])
|
|
581
|
+
if not properties:
|
|
582
|
+
lines.append(" pass")
|
|
583
|
+
else:
|
|
584
|
+
for prop_name in sorted(properties):
|
|
585
|
+
field_name = _safe_identifier(prop_name)
|
|
586
|
+
if field_name != prop_name:
|
|
587
|
+
lines.append(f" {field_name}: Any = Field(default=None, alias={prop_name!r})")
|
|
588
|
+
else:
|
|
589
|
+
lines.append(f" {field_name}: Any = None")
|
|
590
|
+
lines.append("")
|
|
591
|
+
extension_bases = [ref.alias for ref in reversed(extension_refs_for_class)]
|
|
592
|
+
bases = extension_bases + [generated_base_name]
|
|
593
|
+
lines.extend([
|
|
594
|
+
f"class {class_name}({', '.join(bases)}):",
|
|
595
|
+
" pass",
|
|
596
|
+
"",
|
|
597
|
+
])
|
|
598
|
+
|
|
599
|
+
lines.append(f"__all__ = {exports!r}")
|
|
600
|
+
lines.append("")
|
|
601
|
+
return "\n".join(lines)
|
|
602
|
+
|
|
603
|
+
def _method_name(group_name: str, command_name: str) -> str:
|
|
604
|
+
return f"{_safe_identifier(group_name)}_{_safe_identifier(command_name)}"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _validate_class_name(name: str) -> str:
|
|
608
|
+
"""Validate a generated class name or extension class name."""
|
|
609
|
+
|
|
610
|
+
if not re.fullmatch(r"[A-Za-z_]\w*", name) or keyword.iskeyword(name):
|
|
611
|
+
raise ValueError(f"Invalid generated operations class name: {name!r}")
|
|
612
|
+
return name
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _param_signature(
|
|
616
|
+
parameters: list[Any],
|
|
617
|
+
has_request_body: bool,
|
|
618
|
+
*,
|
|
619
|
+
raw_body_override: bool = False,
|
|
620
|
+
) -> tuple[str, list[str], list[str], list[str], set[str], bool]:
|
|
621
|
+
required: list[Any] = []
|
|
622
|
+
optional: list[Any] = []
|
|
623
|
+
for parameter in parameters:
|
|
624
|
+
(required if parameter.required else optional).append(parameter)
|
|
625
|
+
ordered = required + optional
|
|
626
|
+
seen: set[str] = set()
|
|
627
|
+
signature_parts: list[str] = []
|
|
628
|
+
path_names: list[str] = []
|
|
629
|
+
query_names: list[str] = []
|
|
630
|
+
body_names: list[str] = []
|
|
631
|
+
required_body_names: set[str] = set()
|
|
632
|
+
for parameter in ordered:
|
|
633
|
+
name = _safe_identifier(parameter.local_name)
|
|
634
|
+
if name in seen:
|
|
635
|
+
continue
|
|
636
|
+
seen.add(name)
|
|
637
|
+
if parameter.required:
|
|
638
|
+
signature_parts.append(f"{name}: Any")
|
|
639
|
+
else:
|
|
640
|
+
signature_parts.append(f"{name}: Any = None")
|
|
641
|
+
if parameter.location == "path":
|
|
642
|
+
path_names.append(name)
|
|
643
|
+
elif parameter.location == "query":
|
|
644
|
+
query_names.append(name)
|
|
645
|
+
elif parameter.location == "body":
|
|
646
|
+
body_names.append(name)
|
|
647
|
+
if parameter.required:
|
|
648
|
+
required_body_names.add(name)
|
|
649
|
+
include_body = has_request_body and not body_names
|
|
650
|
+
if include_body:
|
|
651
|
+
body_annotation = "dict[str, Any] | None" if raw_body_override else "Any"
|
|
652
|
+
signature_parts.append(f"body: {body_annotation} = None")
|
|
653
|
+
return ", ".join(signature_parts), path_names, query_names, body_names, required_body_names, include_body
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _dict_literal(names: list[str]) -> str:
|
|
657
|
+
if not names:
|
|
658
|
+
return "None"
|
|
659
|
+
return "{" + ", ".join(f"{name!r}: {name}" for name in names) + "}"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _body_dict_literal(names: list[str], required_names: set[str]) -> str:
|
|
663
|
+
if not names:
|
|
664
|
+
return "None"
|
|
665
|
+
optional_names = [name for name in names if name not in required_names]
|
|
666
|
+
if not optional_names:
|
|
667
|
+
return _dict_literal(names)
|
|
668
|
+
optional_literal = _dict_literal(optional_names)
|
|
669
|
+
optional_expr = f"key: value for key, value in {optional_literal}.items() if value is not None"
|
|
670
|
+
if not required_names:
|
|
671
|
+
return "{" + optional_expr + "}"
|
|
672
|
+
required_literal = _dict_literal([name for name in names if name in required_names])
|
|
673
|
+
return "{" + f"**{required_literal}, **{{{optional_expr}}}" + "}"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _validate_operation_path(path: str) -> None:
|
|
677
|
+
"""Reject OpenAPI operation paths that could override the configured origin."""
|
|
678
|
+
|
|
679
|
+
parsed_path = urllib.parse.urlparse(path)
|
|
680
|
+
if parsed_path.scheme or parsed_path.netloc:
|
|
681
|
+
raise ValueError(f"OpenAPI operation path must be relative: {path!r}")
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def generate_operations(
|
|
685
|
+
schema: dict[str, Any],
|
|
686
|
+
operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME,
|
|
687
|
+
model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None,
|
|
688
|
+
request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None = None,
|
|
689
|
+
) -> str:
|
|
690
|
+
"""Generate the static operation mixin class for parsed OpenAPI operations."""
|
|
691
|
+
|
|
692
|
+
operations_class_name = _validate_class_name(operations_class_name)
|
|
693
|
+
schema = _apply_request_body_schema_overrides(schema, request_body_schemas)
|
|
694
|
+
operations = parsed_operations(schema)
|
|
695
|
+
_, model_ref_aliases = _apply_model_aliases(
|
|
696
|
+
schema.get("components", {}).get("schemas", {}) or {},
|
|
697
|
+
model_aliases,
|
|
698
|
+
)
|
|
699
|
+
response_models = sorted({name for op in operations if (name := _response_model_name(op.operation, model_ref_aliases))})
|
|
700
|
+
imports = ", ".join(response_models)
|
|
701
|
+
lines: list[str] = [
|
|
702
|
+
'"""Generated static endpoint methods for the Tangle API.\n\nDo not edit by hand; run ``uv run python -m tangle_cli.openapi.codegen``.\n"""',
|
|
703
|
+
"",
|
|
704
|
+
"from __future__ import annotations",
|
|
705
|
+
"",
|
|
706
|
+
"from collections.abc import Mapping",
|
|
707
|
+
"from typing import TYPE_CHECKING, Any",
|
|
708
|
+
"",
|
|
709
|
+
]
|
|
710
|
+
if imports:
|
|
711
|
+
lines.extend([f"from .models import {imports}", ""])
|
|
712
|
+
|
|
713
|
+
lines.extend([
|
|
714
|
+
"",
|
|
715
|
+
f"class {operations_class_name}:",
|
|
716
|
+
" \"\"\"Generated checked-in methods for Tangle API operations.\"\"\"",
|
|
717
|
+
"",
|
|
718
|
+
" if TYPE_CHECKING:",
|
|
719
|
+
" def _request_json(",
|
|
720
|
+
" self,",
|
|
721
|
+
" method: str,",
|
|
722
|
+
" path: str,",
|
|
723
|
+
" *,",
|
|
724
|
+
" path_params: Mapping[str, Any] | None = None,",
|
|
725
|
+
" params: Mapping[str, Any] | None = None,",
|
|
726
|
+
" json_data: Any = None,",
|
|
727
|
+
" response_model: Any = None,",
|
|
728
|
+
" ) -> Any: ...",
|
|
729
|
+
"",
|
|
730
|
+
])
|
|
731
|
+
|
|
732
|
+
used_methods: set[str] = set()
|
|
733
|
+
for operation in operations:
|
|
734
|
+
_validate_operation_path(operation.path)
|
|
735
|
+
method_name = _method_name(operation.group_name, operation.command_name)
|
|
736
|
+
if method_name in used_methods:
|
|
737
|
+
raise RuntimeError(f"duplicate generated method {method_name}")
|
|
738
|
+
used_methods.add(method_name)
|
|
739
|
+
signature, path_names, query_names, body_names, required_body_names, include_body = _param_signature(
|
|
740
|
+
list(operation.parameters),
|
|
741
|
+
operation.has_request_body,
|
|
742
|
+
raw_body_override=bool(operation.operation.get("x-tangle-cli-request-body-schema-override")),
|
|
743
|
+
)
|
|
744
|
+
response_model = _response_model_name(operation.operation, model_ref_aliases)
|
|
745
|
+
response_arg = response_model if response_model else "None"
|
|
746
|
+
response_annotation = _response_return_annotation(operation.operation, model_ref_aliases)
|
|
747
|
+
if signature:
|
|
748
|
+
def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:"
|
|
749
|
+
else:
|
|
750
|
+
def_line = f" def {method_name}(self) -> {response_annotation}:"
|
|
751
|
+
lines.extend([
|
|
752
|
+
def_line,
|
|
753
|
+
f" return self._request_json(",
|
|
754
|
+
f" {operation.method.upper()!r},",
|
|
755
|
+
f" {operation.path!r},",
|
|
756
|
+
f" path_params={_dict_literal(path_names)},",
|
|
757
|
+
f" params={_dict_literal(query_names)},",
|
|
758
|
+
])
|
|
759
|
+
if body_names:
|
|
760
|
+
lines.append(f" json_data={_body_dict_literal(body_names, required_body_names)},")
|
|
761
|
+
elif include_body:
|
|
762
|
+
lines.append(" json_data=body,")
|
|
763
|
+
else:
|
|
764
|
+
lines.append(" json_data=None,")
|
|
765
|
+
lines.extend([
|
|
766
|
+
f" response_model={response_arg},",
|
|
767
|
+
" )",
|
|
768
|
+
"",
|
|
769
|
+
])
|
|
770
|
+
|
|
771
|
+
lines.append(f"__all__ = {[operations_class_name]!r}")
|
|
772
|
+
lines.append("")
|
|
773
|
+
return "\n".join(lines)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def update_openapi_from_url(
|
|
777
|
+
openapi_url: str,
|
|
778
|
+
*,
|
|
779
|
+
destination: str | Path = DEFAULT_OPENAPI_PATH,
|
|
780
|
+
) -> Path:
|
|
781
|
+
"""Fetch a remote OpenAPI JSON document and write it to *destination*."""
|
|
782
|
+
|
|
783
|
+
request = urllib.request.Request(openapi_url, headers={"User-Agent": "tangle-cli-codegen"})
|
|
784
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
785
|
+
payload = response.read()
|
|
786
|
+
schema = json.loads(payload.decode("utf-8"))
|
|
787
|
+
return write_openapi_schema(schema, destination)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def update_openapi_from_backend(
|
|
791
|
+
*,
|
|
792
|
+
backend_path: str | Path = DEFAULT_BACKEND_PATH,
|
|
793
|
+
destination: str | Path = DEFAULT_OPENAPI_PATH,
|
|
794
|
+
database_uri: str | None = None,
|
|
795
|
+
) -> Path:
|
|
796
|
+
"""Import the official backend FastAPI app and write its OpenAPI schema."""
|
|
797
|
+
|
|
798
|
+
schema = load_openapi_from_backend(backend_path, database_uri=database_uri)
|
|
799
|
+
return write_openapi_schema(schema, destination)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def load_openapi_from_backend(
|
|
803
|
+
backend_path: str | Path = DEFAULT_BACKEND_PATH,
|
|
804
|
+
*,
|
|
805
|
+
database_uri: str | None = None,
|
|
806
|
+
) -> dict[str, Any]:
|
|
807
|
+
"""Return ``api_server_main.app.openapi()`` from a backend checkout.
|
|
808
|
+
|
|
809
|
+
The backend creates a database engine at import time, so codegen points it at
|
|
810
|
+
a temporary SQLite database unless an explicit URI is supplied.
|
|
811
|
+
"""
|
|
812
|
+
|
|
813
|
+
backend_dir = Path(backend_path).resolve()
|
|
814
|
+
if not (backend_dir / "api_server_main.py").exists():
|
|
815
|
+
raise FileNotFoundError(f"{backend_dir} does not contain api_server_main.py")
|
|
816
|
+
|
|
817
|
+
old_path = list(sys.path)
|
|
818
|
+
old_database_uri = os.environ.get("DATABASE_URI")
|
|
819
|
+
old_database_url = os.environ.get("DATABASE_URL")
|
|
820
|
+
old_modules = {
|
|
821
|
+
name: module
|
|
822
|
+
for name, module in sys.modules.items()
|
|
823
|
+
if name == "api_server_main" or name.startswith("cloud_pipelines_backend")
|
|
824
|
+
}
|
|
825
|
+
for name in list(old_modules):
|
|
826
|
+
sys.modules.pop(name, None)
|
|
827
|
+
|
|
828
|
+
with tempfile.TemporaryDirectory(prefix="tangle-openapi-codegen-") as tmpdir:
|
|
829
|
+
os.environ["DATABASE_URI"] = database_uri or f"sqlite:///{Path(tmpdir) / 'openapi_codegen.sqlite'}"
|
|
830
|
+
os.environ.pop("DATABASE_URL", None)
|
|
831
|
+
sys.path.insert(0, str(backend_dir))
|
|
832
|
+
try:
|
|
833
|
+
api_server_main = importlib.import_module("api_server_main")
|
|
834
|
+
schema = api_server_main.app.openapi()
|
|
835
|
+
finally:
|
|
836
|
+
sys.path[:] = old_path
|
|
837
|
+
for name in [
|
|
838
|
+
name
|
|
839
|
+
for name in sys.modules
|
|
840
|
+
if name == "api_server_main" or name.startswith("cloud_pipelines_backend")
|
|
841
|
+
]:
|
|
842
|
+
sys.modules.pop(name, None)
|
|
843
|
+
sys.modules.update(old_modules)
|
|
844
|
+
if old_database_uri is None:
|
|
845
|
+
os.environ.pop("DATABASE_URI", None)
|
|
846
|
+
else:
|
|
847
|
+
os.environ["DATABASE_URI"] = old_database_uri
|
|
848
|
+
if old_database_url is None:
|
|
849
|
+
os.environ.pop("DATABASE_URL", None)
|
|
850
|
+
else:
|
|
851
|
+
os.environ["DATABASE_URL"] = old_database_url
|
|
852
|
+
|
|
853
|
+
if not isinstance(schema, dict) or "paths" not in schema:
|
|
854
|
+
raise ValueError(f"Backend at {backend_dir} did not produce an OpenAPI paths object")
|
|
855
|
+
return schema
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def write_openapi_schema(schema: dict[str, Any], destination: str | Path = DEFAULT_OPENAPI_PATH) -> Path:
|
|
859
|
+
"""Write *schema* as the checked-in OpenAPI snapshot."""
|
|
860
|
+
|
|
861
|
+
if not isinstance(schema, dict) or "paths" not in schema:
|
|
862
|
+
raise ValueError("OpenAPI schema did not contain paths")
|
|
863
|
+
destination_path = Path(destination)
|
|
864
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
865
|
+
destination_path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
866
|
+
return destination_path
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def generate(
|
|
870
|
+
openapi_path: str | Path | None = None,
|
|
871
|
+
generated_dir: str | Path = _GENERATED_DIR,
|
|
872
|
+
*,
|
|
873
|
+
operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME,
|
|
874
|
+
model_extension_module: str | Sequence[str] | None = None,
|
|
875
|
+
model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None,
|
|
876
|
+
request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None = None,
|
|
877
|
+
) -> tuple[dict[str, Any], list[Path]]:
|
|
878
|
+
schema = load_openapi_schema(openapi_path)
|
|
879
|
+
output_dir = Path(generated_dir)
|
|
880
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
881
|
+
generated_files = [
|
|
882
|
+
output_dir / "__init__.py",
|
|
883
|
+
output_dir / "models.py",
|
|
884
|
+
output_dir / "operations.py",
|
|
885
|
+
]
|
|
886
|
+
generated_files[0].write_text(
|
|
887
|
+
'"""Generated OpenAPI support modules."""\n',
|
|
888
|
+
encoding="utf-8",
|
|
889
|
+
)
|
|
890
|
+
generated_files[1].write_text(
|
|
891
|
+
generate_models(
|
|
892
|
+
schema,
|
|
893
|
+
model_extension_module=model_extension_module,
|
|
894
|
+
model_aliases=model_aliases,
|
|
895
|
+
),
|
|
896
|
+
encoding="utf-8",
|
|
897
|
+
)
|
|
898
|
+
generated_files[2].write_text(
|
|
899
|
+
generate_operations(
|
|
900
|
+
schema,
|
|
901
|
+
operations_class_name=operations_class_name,
|
|
902
|
+
model_aliases=model_aliases,
|
|
903
|
+
request_body_schemas=request_body_schemas,
|
|
904
|
+
),
|
|
905
|
+
encoding="utf-8",
|
|
906
|
+
)
|
|
907
|
+
return schema, generated_files
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _default_snapshot_source() -> str:
|
|
911
|
+
if DEFAULT_OPENAPI_PATH.exists():
|
|
912
|
+
return f"snapshot: {_display_path(DEFAULT_OPENAPI_PATH)}"
|
|
913
|
+
return f"snapshot: {DEFAULT_OPENAPI_RESOURCE_PACKAGE}/{DEFAULT_OPENAPI_RESOURCE_NAME}"
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _display_path(path: str | Path) -> str:
|
|
917
|
+
resolved = Path(path).resolve()
|
|
918
|
+
try:
|
|
919
|
+
return str(resolved.relative_to(Path.cwd().resolve()))
|
|
920
|
+
except ValueError:
|
|
921
|
+
return str(path)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _print_summary(
|
|
925
|
+
*,
|
|
926
|
+
source: str,
|
|
927
|
+
openapi_path: str | Path,
|
|
928
|
+
generated_files: list[Path],
|
|
929
|
+
schema: dict[str, Any],
|
|
930
|
+
wrote_openapi: bool,
|
|
931
|
+
) -> None:
|
|
932
|
+
print(f"Loaded OpenAPI from {source}")
|
|
933
|
+
if wrote_openapi:
|
|
934
|
+
print(f"Wrote {_display_path(openapi_path)}")
|
|
935
|
+
for path in generated_files:
|
|
936
|
+
print(f"Wrote {_display_path(path)}")
|
|
937
|
+
print(f"Generated {len(parsed_operations(schema))} operations from {len(schema.get('paths', {}))} paths")
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def main(argv: list[str] | None = None) -> None:
|
|
941
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
942
|
+
parser.add_argument(
|
|
943
|
+
"--openapi",
|
|
944
|
+
default=None,
|
|
945
|
+
help=(
|
|
946
|
+
"Path to openapi.json. Defaults to the official snapshot in "
|
|
947
|
+
"packages/tangle-api/src/tangle_api/schema/openapi.json, or the "
|
|
948
|
+
"packaged tangle_api.schema snapshot when installed."
|
|
949
|
+
),
|
|
950
|
+
)
|
|
951
|
+
parser.add_argument(
|
|
952
|
+
"--out",
|
|
953
|
+
default=str(_GENERATED_DIR),
|
|
954
|
+
help="Generated support module directory (default: packages/tangle-api/src/tangle_api/generated).",
|
|
955
|
+
)
|
|
956
|
+
parser.add_argument(
|
|
957
|
+
"--operations-class-name",
|
|
958
|
+
default=DEFAULT_OPERATIONS_CLASS_NAME,
|
|
959
|
+
help=(
|
|
960
|
+
"Class name to generate in operations.py "
|
|
961
|
+
f"(default: {DEFAULT_OPERATIONS_CLASS_NAME})."
|
|
962
|
+
),
|
|
963
|
+
)
|
|
964
|
+
parser.add_argument(
|
|
965
|
+
"--model-extension-module",
|
|
966
|
+
action="append",
|
|
967
|
+
default=None,
|
|
968
|
+
help=(
|
|
969
|
+
"Importable module containing a MODEL_EXTENSIONS mapping from "
|
|
970
|
+
"generated model class names to extension class names. Repeat to "
|
|
971
|
+
"compose modules in order; later modules override earlier ones. "
|
|
972
|
+
"The built-in default module is applied first unless an empty string "
|
|
973
|
+
"is passed to disable it. "
|
|
974
|
+
f"(default first: {DEFAULT_MODEL_EXTENSION_MODULE})."
|
|
975
|
+
),
|
|
976
|
+
)
|
|
977
|
+
parser.add_argument(
|
|
978
|
+
"--model-alias",
|
|
979
|
+
action="append",
|
|
980
|
+
default=None,
|
|
981
|
+
help=(
|
|
982
|
+
"Expose a stable public model class from one or more source schemas, "
|
|
983
|
+
"using PublicModel=SourceSchema[,OtherSourceSchema]. Repeat for "
|
|
984
|
+
"multiple aliases. The built-in ComponentSpec alias is applied first "
|
|
985
|
+
"unless an empty string is passed to disable defaults."
|
|
986
|
+
),
|
|
987
|
+
)
|
|
988
|
+
parser.add_argument(
|
|
989
|
+
"--request-body-schema",
|
|
990
|
+
action="append",
|
|
991
|
+
default=None,
|
|
992
|
+
help=(
|
|
993
|
+
"Override an operation JSON request-body schema using "
|
|
994
|
+
"OperationId={...json schema...}. OperationId may be the OpenAPI "
|
|
995
|
+
"operationId, generated method name, or group.command name. Repeat "
|
|
996
|
+
"for multiple operations."
|
|
997
|
+
),
|
|
998
|
+
)
|
|
999
|
+
parser.add_argument(
|
|
1000
|
+
"--request-body-schema-file",
|
|
1001
|
+
action="append",
|
|
1002
|
+
default=None,
|
|
1003
|
+
help=(
|
|
1004
|
+
"Override an operation JSON request-body schema from a JSON file "
|
|
1005
|
+
"using OperationId=path/to/schema.json. Repeat for multiple operations."
|
|
1006
|
+
),
|
|
1007
|
+
)
|
|
1008
|
+
parser.add_argument(
|
|
1009
|
+
"--openapi-url",
|
|
1010
|
+
default=None,
|
|
1011
|
+
help="Remote OpenAPI JSON URL to fetch before regenerating.",
|
|
1012
|
+
)
|
|
1013
|
+
parser.add_argument(
|
|
1014
|
+
"--backend-path",
|
|
1015
|
+
default=None,
|
|
1016
|
+
help=(
|
|
1017
|
+
"Backend checkout/submodule path to import for OpenAPI generation "
|
|
1018
|
+
f"(default: {_display_path(DEFAULT_BACKEND_PATH)})."
|
|
1019
|
+
),
|
|
1020
|
+
)
|
|
1021
|
+
parser.add_argument(
|
|
1022
|
+
"--backend-database-uri",
|
|
1023
|
+
default=None,
|
|
1024
|
+
help="Database URI used while importing the backend app; defaults to a temporary SQLite DB.",
|
|
1025
|
+
)
|
|
1026
|
+
parser.add_argument(
|
|
1027
|
+
"--from-snapshot",
|
|
1028
|
+
action="store_true",
|
|
1029
|
+
help="Regenerate support modules from the official API-package openapi.json snapshot.",
|
|
1030
|
+
)
|
|
1031
|
+
args = parser.parse_args(argv)
|
|
1032
|
+
try:
|
|
1033
|
+
_validate_class_name(args.operations_class_name)
|
|
1034
|
+
_model_extension_refs(args.model_extension_module)
|
|
1035
|
+
_model_alias_mapping(args.model_alias)
|
|
1036
|
+
request_body_schema_overrides = _request_body_schema_mapping(args.request_body_schema)
|
|
1037
|
+
request_body_schema_overrides.update(_request_body_schema_file_mapping(args.request_body_schema_file))
|
|
1038
|
+
if not request_body_schema_overrides:
|
|
1039
|
+
request_body_schema_overrides = None
|
|
1040
|
+
except ValueError as exc:
|
|
1041
|
+
parser.error(str(exc))
|
|
1042
|
+
source_count = sum(bool(value) for value in (args.openapi_url, args.backend_path, args.from_snapshot))
|
|
1043
|
+
if source_count > 1:
|
|
1044
|
+
parser.error("choose only one OpenAPI source: --openapi-url, --backend-path, or --from-snapshot")
|
|
1045
|
+
|
|
1046
|
+
openapi_path = args.openapi or DEFAULT_OPENAPI_PATH
|
|
1047
|
+
wrote_openapi = False
|
|
1048
|
+
if args.openapi_url:
|
|
1049
|
+
update_openapi_from_url(args.openapi_url, destination=openapi_path)
|
|
1050
|
+
source = f"URL: {args.openapi_url}"
|
|
1051
|
+
wrote_openapi = True
|
|
1052
|
+
elif args.from_snapshot:
|
|
1053
|
+
openapi_path = args.openapi
|
|
1054
|
+
source = f"snapshot: {_display_path(openapi_path)}" if openapi_path else _default_snapshot_source()
|
|
1055
|
+
else:
|
|
1056
|
+
backend_path = Path(args.backend_path) if args.backend_path else DEFAULT_BACKEND_PATH
|
|
1057
|
+
if not (backend_path / "api_server_main.py").exists():
|
|
1058
|
+
if args.backend_path:
|
|
1059
|
+
parser.exit(1, f"Backend source not found: {_display_path(backend_path)}\n")
|
|
1060
|
+
parser.exit(
|
|
1061
|
+
1,
|
|
1062
|
+
"Default backend submodule not found. Run: git submodule update --init --recursive\n",
|
|
1063
|
+
)
|
|
1064
|
+
update_openapi_from_backend(
|
|
1065
|
+
backend_path=backend_path,
|
|
1066
|
+
destination=openapi_path,
|
|
1067
|
+
database_uri=args.backend_database_uri,
|
|
1068
|
+
)
|
|
1069
|
+
source = f"backend: {_display_path(backend_path)}"
|
|
1070
|
+
wrote_openapi = True
|
|
1071
|
+
|
|
1072
|
+
schema, generated_files = generate(
|
|
1073
|
+
openapi_path,
|
|
1074
|
+
args.out,
|
|
1075
|
+
operations_class_name=args.operations_class_name,
|
|
1076
|
+
model_extension_module=args.model_extension_module,
|
|
1077
|
+
model_aliases=args.model_alias,
|
|
1078
|
+
request_body_schemas=request_body_schema_overrides,
|
|
1079
|
+
)
|
|
1080
|
+
_print_summary(
|
|
1081
|
+
source=source,
|
|
1082
|
+
openapi_path=openapi_path or DEFAULT_OPENAPI_PATH,
|
|
1083
|
+
generated_files=generated_files,
|
|
1084
|
+
schema=schema,
|
|
1085
|
+
wrote_openapi=wrote_openapi,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1090
|
+
main()
|