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.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. 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()