tangle-cli 0.0.1a1__tar.gz → 0.0.1a3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/PKG-INFO +34 -73
  2. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/README.md +32 -71
  3. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/__init__.py +4 -4
  4. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/api_cli.py +8 -4
  5. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +7 -6
  6. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/client.py +8 -1
  7. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/component_publisher.py +3 -3
  8. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/handler.py +1 -1
  9. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/models.py +51 -5
  10. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +46 -192
  11. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +4 -3
  12. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +3 -3
  13. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/quickstart.py +11 -12
  14. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/pyproject.toml +5 -2
  15. tangle_cli-0.0.1a1/packages/tangle-cli/src/tangle_cli/generated_runtime.py +0 -43
  16. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  17. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  18. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/args_container.py +0 -0
  19. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  20. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  21. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  22. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  23. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/component_from_func.py +0 -0
  24. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  25. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  26. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  27. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  28. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  29. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  30. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  31. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  32. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  33. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  34. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  35. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  36. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  37. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  38. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  39. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  40. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipelines.py +0 -0
  41. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  42. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  43. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  44. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  45. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  46. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  47. {tangle_cli-0.0.1a1 → tangle_cli-0.0.1a3}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tangle-cli
3
- Version: 0.0.1a1
3
+ Version: 0.0.1a3
4
4
  Summary: CLI for Tangle, the open-source ML pipeline orchestration platform
5
5
  Author: Alexey Volkov, Tangle authors
6
6
  Author-email: Alexey Volkov <alexey.volkov@ark-kun.com>
@@ -13,8 +13,8 @@ Requires-Dist: platformdirs>=4.10.0
13
13
  Requires-Dist: pydantic>=2.0
14
14
  Requires-Dist: pyyaml>=6.0
15
15
  Requires-Dist: requests>=2.32.0
16
+ Requires-Dist: tangle-api==0.0.1a3
16
17
  Requires-Dist: tomli>=2.0 ; python_full_version < '3.11'
17
- Requires-Dist: tangle-api==0.1.0 ; extra == 'native'
18
18
  Requires-Python: >=3.10
19
19
  Project-URL: Homepage, https://tangleml.com
20
20
  Project-URL: Documentation, https://tangleml.com/docs/
@@ -58,7 +58,7 @@ By default `tangle api` uses `--schema-source auto`, which means official static
58
58
 
59
59
  `tangle sdk` commands are hand-written workflows. They can be:
60
60
 
61
- - **local-only**: no generated/native API bindings required, e.g. pipeline validation/layout and component generation;
61
+ - **local-only**: no generated API bindings required, e.g. pipeline validation/layout and component generation;
62
62
  - **API-backed**: use the generated client but add domain behavior, e.g. pipeline-run submit payload construction, hydration, artifact lookup, publishing/version checks, or config batching.
63
63
 
64
64
  Current SDK groups include:
@@ -111,22 +111,22 @@ Use `--log-type none` for quiet machine-readable runs, and `--log-type file` to
111
111
  The repository contains two Python import packages with different responsibilities:
112
112
 
113
113
  - `tangle_cli` is hand-written. It contains CLI wiring, SDK/business helpers, local pipeline/component workflows, dynamic API discovery, codegen, shared runtime classes, logging, and extension classes.
114
- - `tangle_api` is generated/native. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot.
114
+ - `tangle_api` is generated/static. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot.
115
115
 
116
- The default `tangle-cli` package keeps the top-level import and local-only SDK commands native-free. Install the native extra when you want static API-backed commands and the handwritten `TangleApiClient` wrapper to use the checked-in generated bindings:
116
+ The default public `tangle-cli` package depends on the matching `tangle-api` package, so normal installs include the checked-in generated bindings used by static API-backed commands and the handwritten `TangleApiClient` wrapper:
117
117
 
118
118
  ```bash
119
- pip install 'tangle-cli[native]'
119
+ pip install tangle-cli
120
120
  ```
121
121
 
122
- In this workspace, `uv` installs the workspace `tangle-api` package for development and tests:
122
+ The `native` extra remains as a compatibility no-op alias for older install instructions. In this workspace, `uv` installs the workspace `tangle-api` package for development and tests:
123
123
 
124
124
  ```bash
125
125
  uv run tangle api --help
126
126
  uv run tangle sdk pipelines validate pipeline.yaml
127
127
  ```
128
128
 
129
- If you are embedding `tangle_cli` in a downstream project, you can provide your own local `tangle_api.generated` package produced from your backend schema instead of using this repo's official generated package.
129
+ Custom API/codegen users can still run codegen from the fully capable install; generating bindings does not require removing the official `tangle-api` package. For project-local generated APIs, generate into a local source tree such as `src/tangle_api/generated` (and `src/tangle_api/schema/openapi.json` when you want `tangle api --schema-source official`) and run from that project so local `src/tangle_api` shadows site-packages. For packaged custom APIs, publish/provide a distribution named `tangle-api` with a version compatible with this `tangle-cli` release (for example `0.0.1a3+yourorg` for a `tangle-cli` dependency on `tangle-api==0.0.1a3`) via a private index, `--find-links`, or uv sources. As an expert escape hatch, `--no-deps` installs only `tangle-cli` and skips all dependencies, so that environment must manually provide every required runtime dependency plus its generated/custom `tangle_api`; this is acceptable for controlled codegen/custom scenarios but not normal UX.
130
130
 
131
131
  ## Quick command examples
132
132
 
@@ -229,9 +229,9 @@ uv run tangle api reset-cache --base-url https://api.example
229
229
 
230
230
  Schema source modes are:
231
231
 
232
- - `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Requires the native `tangle-api` package for official operations.
233
- - `--schema-source official`: only the checked-in official static schema. Requires the native `tangle-api` package.
234
- - `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. Does not require the native package.
232
+ - `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Normal `tangle-cli` installs include the `tangle-api` package needed for official operations; custom API projects can shadow or replace that package as described in the codegen section.
233
+ - `--schema-source official`: only the checked-in official static schema from `tangle-api` (or a compatible custom `tangle-api` package on your environment's import path).
234
+ - `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. This is the custom/source-checkout fallback when a consumer environment does not provide an importable `tangle_api.schema` package.
235
235
 
236
236
  For resource help, put `--schema-source` on the resource group:
237
237
 
@@ -331,7 +331,7 @@ existing = client.find_existing_components(
331
331
 
332
332
  `TangleApiClient` is handwritten in `tangle_cli.client` and inherits generated endpoint methods from `tangle_api.generated.operations.GeneratedTangleApiOperations`. The generated endpoint methods call the handwritten transport/request logic. Handwritten semantic helpers such as `find_existing_components(...)` return domain models and normalize common compatibility cases.
333
333
 
334
- The top-level `import tangle_cli` is lightweight and does not import native static bindings. Install the native extra or otherwise provide a local `tangle_api.generated` package before importing `tangle_cli.client`.
334
+ The top-level `import tangle_cli` is lightweight and does not import static bindings eagerly. Normal installs include `tangle-api`; source checkouts or downstream embeddings may instead provide a local `tangle_api.generated` package before importing `tangle_cli.client`.
335
335
 
336
336
  ## Codegen/autogen from OpenAPI
337
337
 
@@ -362,6 +362,17 @@ uv run python -m tangle_cli.openapi.codegen \
362
362
  --out src/tangle_api/generated
363
363
  ```
364
364
 
365
+ For a project-local custom API package, write both the schema snapshot and generated modules under that project's source tree, then run tools/tests from the project environment so `src/tangle_api` is earlier on `sys.path` than the official site-packages package:
366
+
367
+ ```bash
368
+ uv run python -m tangle_cli.openapi.codegen \
369
+ --openapi-url https://api.example/openapi.json \
370
+ --openapi src/tangle_api/schema/openapi.json \
371
+ --out src/tangle_api/generated
372
+ ```
373
+
374
+ That project-local `tangle_api` package can be an editable/package source tree. If you ship the custom API bindings as a wheel or source distribution, use the distribution name `tangle-api` and a compatible version for the `tangle-cli` release you are using. A PEP 440 local version such as `0.0.1a3+yourorg` can satisfy a public `==0.0.1a3` dependency while distinguishing your private build. Provide that package through your private index, `--find-links`, or uv source configuration so the resolver chooses it instead of the public official package.
375
+
365
376
  Generate from a backend checkout explicitly:
366
377
 
367
378
  ```bash
@@ -372,48 +383,29 @@ uv run --group codegen python -m tangle_cli.openapi.codegen \
372
383
 
373
384
  Important codegen options:
374
385
 
375
- - `--out`: directory that receives `__init__.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`.
386
+ - `--out`: directory that receives `__init__.py`, `runtime.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`.
376
387
  - `--operations-class-name`: generated operations mixin class name. Defaults to `GeneratedTangleApiOperations`.
377
- - `--model-extension-module`: importable module with `MODEL_EXTENSIONS`; repeat to compose modules.
378
388
  - `--model-alias`: expose a stable public model name from one or more source schema names, e.g. `ComponentSpec=ComponentSpecOutput,ComponentSpecInput`.
379
389
  - `--request-body-schema` / `--request-body-schema-file`: override a specific operation's JSON request-body schema without mutating the fetched OpenAPI document.
380
390
 
381
391
  At runtime, more `tangle api ...` commands become available in two ways:
382
392
 
383
- 1. Static codegen: regenerate and install/provide a `tangle_api.generated` package for the schema.
393
+ 1. Static codegen: regenerate and install/provide a local or packaged `tangle_api` package containing `tangle_api.generated` and, for official-schema CLI discovery, `tangle_api.schema`.
384
394
  2. Dynamic cache: run `tangle api refresh --base-url ...` and use `--schema-source auto` or `--schema-source cache` to expose cached-only operations through the dynamic CLI.
385
395
 
386
- ## Generated model extension pattern
396
+ The supported workaround hierarchy for custom API consumers is: prefer a project-local `src/tangle_api` package that shadows site-packages for that project; if distributing bindings, prefer a compatible private `tangle-api` distribution; reserve `--no-deps` installs or manual uninstalls of the official package for controlled expert environments where you manually provide all dependencies and the generated/custom `tangle_api` package.
387
397
 
388
- Generated models use a generated implementation base plus a stable public subclass. For example, codegen emits this shape for a model with a handwritten extension:
398
+ ## Runtime generated model extension pattern
399
+
400
+ `tangle_api.generated.models` is a leaf package and codegen emits plain generated Pydantic models directly:
389
401
 
390
402
  ```python
391
- class _ComponentSpecGenerated(TangleGeneratedModel):
403
+ class ComponentSpec(TangleGeneratedModel):
392
404
  name: Any = None
393
405
  # generated OpenAPI fields...
394
-
395
- class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated):
396
- pass
397
- ```
398
-
399
- The public class is a subclass rather than an alias because the public class name is the stable contract while the generated base can be regenerated. Subclassing lets the public class keep the OpenAPI/Pydantic fields from `_ComponentSpecGenerated` and add or override behavior through normal Python MRO.
400
-
401
- Extension bases are placed to the **left** of the generated base:
402
-
403
- ```python
404
- class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated):
405
- pass
406
- ```
407
-
408
- That means extension methods/properties override generated-base behavior when names overlap, while generated fields and `TangleGeneratedModel` runtime helpers such as `to_dict()` remain available.
409
-
410
- The built-in default extension module is:
411
-
412
- ```text
413
- tangle_cli.generated_model_extensions
414
406
  ```
415
407
 
416
- It defines:
408
+ Generated models do not import `tangle_cli` and codegen does not bake downstream extension modules into `tangle_api`. Downstream packages compose their own extended model namespace at runtime. In `tangle_cli.models`, the default CLI mixins are declared in `tangle_cli.generated_model_extensions`:
417
409
 
418
410
  ```python
419
411
  MODEL_EXTENSIONS = {
@@ -423,42 +415,11 @@ MODEL_EXTENSIONS = {
423
415
  }
424
416
  ```
425
417
 
426
- During codegen, `tangle_api.generated.models` imports those extension classes from `tangle_cli.generated_model_extensions`. This preserves the package boundary: `tangle_api` remains generated bindings, while `tangle_cli` owns handwritten runtime and extension behavior.
427
-
428
- Downstream projects can layer their own extensions:
429
-
430
- ```python
431
- # my_project/tangle_model_extensions.py
432
- class MyComponentSpecExtensions:
433
- @property
434
- def owning_team(self) -> str | None:
435
- return (self.metadata or {}).get("annotations", {}).get("team")
436
-
437
- MODEL_EXTENSIONS = {
438
- "ComponentSpec": "MyComponentSpecExtensions",
439
- }
440
- ```
441
-
442
- ```bash
443
- uv run python -m tangle_cli.openapi.codegen \
444
- --openapi-url https://api.example/openapi.json \
445
- --out src/tangle_api/generated \
446
- --model-extension-module my_project.tangle_model_extensions
447
- ```
448
-
449
- The default module is applied first. Repeated `--model-extension-module` values are applied in order, and later/downstream modules become leftmost in the generated public class MRO, so they override earlier/default extensions. If two modules export the same extension class name, codegen imports them with deterministic aliases.
450
-
451
- Pass an empty string to disable built-in default extensions:
452
-
453
- ```bash
454
- uv run python -m tangle_cli.openapi.codegen \
455
- --from-snapshot \
456
- --model-extension-module ""
457
- ```
418
+ `tangle_cli.models.compose_models(...)` reads those mappings and creates subclasses in the `tangle_cli.models` namespace, e.g. `ComponentSpec(ComponentSpecExtensions, tangle_api.generated.models.ComponentSpec)`, without mutating `tangle_api.generated.models`. The generated operations layer also calls `_response_model(model_name, default)` so `TangleApiClient` can deserialize responses into the CLI-composed classes while the base `GeneratedTangleApiOperations` remains downstream-agnostic.
458
419
 
459
- The same empty-string sentinel can disable built-in `--model-alias` defaults. Built-in aliases keep stable public model names such as `ComponentSpec` even when a backend schema uses names like `ComponentSpecOutput` or `ComponentSpecInput`.
420
+ Downstream projects can use the same pattern in their own namespace: import base classes from `tangle_api.generated.models`, define method/property-only mixins plus a `MODEL_EXTENSIONS` mapping, and compose subclasses locally. Avoid global monkey-patching of `tangle_api.generated.models`.
460
421
 
461
- Extension classes should be importable from their modules and should not import generated model classes. They should be mixins over generated data, not replacements for generated schemas.
422
+ Built-in `--model-alias` defaults still keep stable public model names such as `ComponentSpec` even when a backend schema uses names like `ComponentSpecOutput` or `ComponentSpecInput`.
462
423
 
463
424
  ## Extending SDK behavior
464
425
 
@@ -33,7 +33,7 @@ By default `tangle api` uses `--schema-source auto`, which means official static
33
33
 
34
34
  `tangle sdk` commands are hand-written workflows. They can be:
35
35
 
36
- - **local-only**: no generated/native API bindings required, e.g. pipeline validation/layout and component generation;
36
+ - **local-only**: no generated API bindings required, e.g. pipeline validation/layout and component generation;
37
37
  - **API-backed**: use the generated client but add domain behavior, e.g. pipeline-run submit payload construction, hydration, artifact lookup, publishing/version checks, or config batching.
38
38
 
39
39
  Current SDK groups include:
@@ -86,22 +86,22 @@ Use `--log-type none` for quiet machine-readable runs, and `--log-type file` to
86
86
  The repository contains two Python import packages with different responsibilities:
87
87
 
88
88
  - `tangle_cli` is hand-written. It contains CLI wiring, SDK/business helpers, local pipeline/component workflows, dynamic API discovery, codegen, shared runtime classes, logging, and extension classes.
89
- - `tangle_api` is generated/native. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot.
89
+ - `tangle_api` is generated/static. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot.
90
90
 
91
- The default `tangle-cli` package keeps the top-level import and local-only SDK commands native-free. Install the native extra when you want static API-backed commands and the handwritten `TangleApiClient` wrapper to use the checked-in generated bindings:
91
+ The default public `tangle-cli` package depends on the matching `tangle-api` package, so normal installs include the checked-in generated bindings used by static API-backed commands and the handwritten `TangleApiClient` wrapper:
92
92
 
93
93
  ```bash
94
- pip install 'tangle-cli[native]'
94
+ pip install tangle-cli
95
95
  ```
96
96
 
97
- In this workspace, `uv` installs the workspace `tangle-api` package for development and tests:
97
+ The `native` extra remains as a compatibility no-op alias for older install instructions. In this workspace, `uv` installs the workspace `tangle-api` package for development and tests:
98
98
 
99
99
  ```bash
100
100
  uv run tangle api --help
101
101
  uv run tangle sdk pipelines validate pipeline.yaml
102
102
  ```
103
103
 
104
- If you are embedding `tangle_cli` in a downstream project, you can provide your own local `tangle_api.generated` package produced from your backend schema instead of using this repo's official generated package.
104
+ Custom API/codegen users can still run codegen from the fully capable install; generating bindings does not require removing the official `tangle-api` package. For project-local generated APIs, generate into a local source tree such as `src/tangle_api/generated` (and `src/tangle_api/schema/openapi.json` when you want `tangle api --schema-source official`) and run from that project so local `src/tangle_api` shadows site-packages. For packaged custom APIs, publish/provide a distribution named `tangle-api` with a version compatible with this `tangle-cli` release (for example `0.0.1a3+yourorg` for a `tangle-cli` dependency on `tangle-api==0.0.1a3`) via a private index, `--find-links`, or uv sources. As an expert escape hatch, `--no-deps` installs only `tangle-cli` and skips all dependencies, so that environment must manually provide every required runtime dependency plus its generated/custom `tangle_api`; this is acceptable for controlled codegen/custom scenarios but not normal UX.
105
105
 
106
106
  ## Quick command examples
107
107
 
@@ -204,9 +204,9 @@ uv run tangle api reset-cache --base-url https://api.example
204
204
 
205
205
  Schema source modes are:
206
206
 
207
- - `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Requires the native `tangle-api` package for official operations.
208
- - `--schema-source official`: only the checked-in official static schema. Requires the native `tangle-api` package.
209
- - `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. Does not require the native package.
207
+ - `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Normal `tangle-cli` installs include the `tangle-api` package needed for official operations; custom API projects can shadow or replace that package as described in the codegen section.
208
+ - `--schema-source official`: only the checked-in official static schema from `tangle-api` (or a compatible custom `tangle-api` package on your environment's import path).
209
+ - `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. This is the custom/source-checkout fallback when a consumer environment does not provide an importable `tangle_api.schema` package.
210
210
 
211
211
  For resource help, put `--schema-source` on the resource group:
212
212
 
@@ -306,7 +306,7 @@ existing = client.find_existing_components(
306
306
 
307
307
  `TangleApiClient` is handwritten in `tangle_cli.client` and inherits generated endpoint methods from `tangle_api.generated.operations.GeneratedTangleApiOperations`. The generated endpoint methods call the handwritten transport/request logic. Handwritten semantic helpers such as `find_existing_components(...)` return domain models and normalize common compatibility cases.
308
308
 
309
- The top-level `import tangle_cli` is lightweight and does not import native static bindings. Install the native extra or otherwise provide a local `tangle_api.generated` package before importing `tangle_cli.client`.
309
+ The top-level `import tangle_cli` is lightweight and does not import static bindings eagerly. Normal installs include `tangle-api`; source checkouts or downstream embeddings may instead provide a local `tangle_api.generated` package before importing `tangle_cli.client`.
310
310
 
311
311
  ## Codegen/autogen from OpenAPI
312
312
 
@@ -337,6 +337,17 @@ uv run python -m tangle_cli.openapi.codegen \
337
337
  --out src/tangle_api/generated
338
338
  ```
339
339
 
340
+ For a project-local custom API package, write both the schema snapshot and generated modules under that project's source tree, then run tools/tests from the project environment so `src/tangle_api` is earlier on `sys.path` than the official site-packages package:
341
+
342
+ ```bash
343
+ uv run python -m tangle_cli.openapi.codegen \
344
+ --openapi-url https://api.example/openapi.json \
345
+ --openapi src/tangle_api/schema/openapi.json \
346
+ --out src/tangle_api/generated
347
+ ```
348
+
349
+ That project-local `tangle_api` package can be an editable/package source tree. If you ship the custom API bindings as a wheel or source distribution, use the distribution name `tangle-api` and a compatible version for the `tangle-cli` release you are using. A PEP 440 local version such as `0.0.1a3+yourorg` can satisfy a public `==0.0.1a3` dependency while distinguishing your private build. Provide that package through your private index, `--find-links`, or uv source configuration so the resolver chooses it instead of the public official package.
350
+
340
351
  Generate from a backend checkout explicitly:
341
352
 
342
353
  ```bash
@@ -347,48 +358,29 @@ uv run --group codegen python -m tangle_cli.openapi.codegen \
347
358
 
348
359
  Important codegen options:
349
360
 
350
- - `--out`: directory that receives `__init__.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`.
361
+ - `--out`: directory that receives `__init__.py`, `runtime.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`.
351
362
  - `--operations-class-name`: generated operations mixin class name. Defaults to `GeneratedTangleApiOperations`.
352
- - `--model-extension-module`: importable module with `MODEL_EXTENSIONS`; repeat to compose modules.
353
363
  - `--model-alias`: expose a stable public model name from one or more source schema names, e.g. `ComponentSpec=ComponentSpecOutput,ComponentSpecInput`.
354
364
  - `--request-body-schema` / `--request-body-schema-file`: override a specific operation's JSON request-body schema without mutating the fetched OpenAPI document.
355
365
 
356
366
  At runtime, more `tangle api ...` commands become available in two ways:
357
367
 
358
- 1. Static codegen: regenerate and install/provide a `tangle_api.generated` package for the schema.
368
+ 1. Static codegen: regenerate and install/provide a local or packaged `tangle_api` package containing `tangle_api.generated` and, for official-schema CLI discovery, `tangle_api.schema`.
359
369
  2. Dynamic cache: run `tangle api refresh --base-url ...` and use `--schema-source auto` or `--schema-source cache` to expose cached-only operations through the dynamic CLI.
360
370
 
361
- ## Generated model extension pattern
371
+ The supported workaround hierarchy for custom API consumers is: prefer a project-local `src/tangle_api` package that shadows site-packages for that project; if distributing bindings, prefer a compatible private `tangle-api` distribution; reserve `--no-deps` installs or manual uninstalls of the official package for controlled expert environments where you manually provide all dependencies and the generated/custom `tangle_api` package.
362
372
 
363
- Generated models use a generated implementation base plus a stable public subclass. For example, codegen emits this shape for a model with a handwritten extension:
373
+ ## Runtime generated model extension pattern
374
+
375
+ `tangle_api.generated.models` is a leaf package and codegen emits plain generated Pydantic models directly:
364
376
 
365
377
  ```python
366
- class _ComponentSpecGenerated(TangleGeneratedModel):
378
+ class ComponentSpec(TangleGeneratedModel):
367
379
  name: Any = None
368
380
  # generated OpenAPI fields...
369
-
370
- class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated):
371
- pass
372
- ```
373
-
374
- The public class is a subclass rather than an alias because the public class name is the stable contract while the generated base can be regenerated. Subclassing lets the public class keep the OpenAPI/Pydantic fields from `_ComponentSpecGenerated` and add or override behavior through normal Python MRO.
375
-
376
- Extension bases are placed to the **left** of the generated base:
377
-
378
- ```python
379
- class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated):
380
- pass
381
- ```
382
-
383
- That means extension methods/properties override generated-base behavior when names overlap, while generated fields and `TangleGeneratedModel` runtime helpers such as `to_dict()` remain available.
384
-
385
- The built-in default extension module is:
386
-
387
- ```text
388
- tangle_cli.generated_model_extensions
389
381
  ```
390
382
 
391
- It defines:
383
+ Generated models do not import `tangle_cli` and codegen does not bake downstream extension modules into `tangle_api`. Downstream packages compose their own extended model namespace at runtime. In `tangle_cli.models`, the default CLI mixins are declared in `tangle_cli.generated_model_extensions`:
392
384
 
393
385
  ```python
394
386
  MODEL_EXTENSIONS = {
@@ -398,42 +390,11 @@ MODEL_EXTENSIONS = {
398
390
  }
399
391
  ```
400
392
 
401
- During codegen, `tangle_api.generated.models` imports those extension classes from `tangle_cli.generated_model_extensions`. This preserves the package boundary: `tangle_api` remains generated bindings, while `tangle_cli` owns handwritten runtime and extension behavior.
402
-
403
- Downstream projects can layer their own extensions:
404
-
405
- ```python
406
- # my_project/tangle_model_extensions.py
407
- class MyComponentSpecExtensions:
408
- @property
409
- def owning_team(self) -> str | None:
410
- return (self.metadata or {}).get("annotations", {}).get("team")
411
-
412
- MODEL_EXTENSIONS = {
413
- "ComponentSpec": "MyComponentSpecExtensions",
414
- }
415
- ```
416
-
417
- ```bash
418
- uv run python -m tangle_cli.openapi.codegen \
419
- --openapi-url https://api.example/openapi.json \
420
- --out src/tangle_api/generated \
421
- --model-extension-module my_project.tangle_model_extensions
422
- ```
423
-
424
- The default module is applied first. Repeated `--model-extension-module` values are applied in order, and later/downstream modules become leftmost in the generated public class MRO, so they override earlier/default extensions. If two modules export the same extension class name, codegen imports them with deterministic aliases.
425
-
426
- Pass an empty string to disable built-in default extensions:
427
-
428
- ```bash
429
- uv run python -m tangle_cli.openapi.codegen \
430
- --from-snapshot \
431
- --model-extension-module ""
432
- ```
393
+ `tangle_cli.models.compose_models(...)` reads those mappings and creates subclasses in the `tangle_cli.models` namespace, e.g. `ComponentSpec(ComponentSpecExtensions, tangle_api.generated.models.ComponentSpec)`, without mutating `tangle_api.generated.models`. The generated operations layer also calls `_response_model(model_name, default)` so `TangleApiClient` can deserialize responses into the CLI-composed classes while the base `GeneratedTangleApiOperations` remains downstream-agnostic.
433
394
 
434
- The same empty-string sentinel can disable built-in `--model-alias` defaults. Built-in aliases keep stable public model names such as `ComponentSpec` even when a backend schema uses names like `ComponentSpecOutput` or `ComponentSpecInput`.
395
+ Downstream projects can use the same pattern in their own namespace: import base classes from `tangle_api.generated.models`, define method/property-only mixins plus a `MODEL_EXTENSIONS` mapping, and compose subclasses locally. Avoid global monkey-patching of `tangle_api.generated.models`.
435
396
 
436
- Extension classes should be importable from their modules and should not import generated model classes. They should be mixins over generated data, not replacements for generated schemas.
397
+ Built-in `--model-alias` defaults still keep stable public model names such as `ComponentSpec` even when a backend schema uses names like `ComponentSpecOutput` or `ComponentSpecInput`.
437
398
 
438
399
  ## Extending SDK behavior
439
400
 
@@ -1,9 +1,9 @@
1
1
  """tangle-cli public API.
2
2
 
3
- The package import is intentionally lightweight: native static API bindings live
4
- in ``tangle_api.generated`` and may be supplied by the consumer environment.
5
- Import ``tangle_cli.client.TangleApiClient`` explicitly when those generated
6
- bindings are available.
3
+ The package import is intentionally lightweight: static API bindings live in
4
+ ``tangle_api.generated`` (included by default in public installs, or supplied by
5
+ source/downstream environments). Import ``tangle_cli.client.TangleApiClient``
6
+ explicitly when generated bindings should be loaded.
7
7
  """
8
8
 
9
9
  from importlib.metadata import PackageNotFoundError
@@ -503,7 +503,8 @@ def _schema_for_current_invocation() -> dict[str, Any] | None:
503
503
  raise SystemExit(
504
504
  f"No cached OpenAPI schema for {_normalize_base_url(base_url)}. "
505
505
  "Run `tangle api refresh` with the same --base-url/--auth-header/--header options, "
506
- "or install tangle-cli[native] to use the official static schema."
506
+ "or use a tangle-cli environment with an official or custom tangle-api package "
507
+ "that provides tangle_api.schema."
507
508
  )
508
509
  return cached
509
510
 
@@ -571,9 +572,12 @@ def _api_tail_requests_help(api_tail: list[str]) -> bool:
571
572
 
572
573
  def _missing_official_schema_message() -> str:
573
574
  return (
574
- "Official static Tangle API commands require the native tangle-api "
575
- "package because the bundled OpenAPI snapshot lives in tangle_api.schema. "
576
- "Install tangle-cli[native], or run `tangle api refresh` and use "
575
+ "Official static Tangle API commands require a tangle-api package "
576
+ "because the OpenAPI snapshot lives in tangle_api.schema. Normal "
577
+ "tangle-cli installs include the official package; custom generated "
578
+ "API projects should run with a local src/tangle_api package that "
579
+ "shadows site-packages or install a compatible private tangle-api "
580
+ "distribution. Otherwise run `tangle api refresh` and use "
577
581
  "`--schema-source cache` for cached backend operations."
578
582
  )
579
583
 
@@ -66,8 +66,8 @@ def api_arg_specs(
66
66
  class LazyTangleApiClient:
67
67
  """Instantiate the generated API client only when a command uses it.
68
68
 
69
- Importing CLI modules must stay native-free so local-only commands can run
70
- without the generated ``tangle_api`` package. This proxy delays importing and
69
+ Importing CLI modules must not eagerly load generated bindings, so local-only
70
+ commands can run without importing ``tangle_api``. This proxy delays importing and
71
71
  constructing ``TangleApiClient`` until an API method is actually accessed,
72
72
  while keeping CLI-friendly error wording in the CLI helper layer.
73
73
  """
@@ -85,9 +85,10 @@ class LazyTangleApiClient:
85
85
  except ModuleNotFoundError as exc:
86
86
  if exc.name == "tangle_api":
87
87
  raise SystemExit(
88
- "Native generated Tangle API bindings are required for "
89
- f"{self.command_name}. Install tangle-cli[native] or provide "
90
- "a local tangle_api.generated package."
88
+ "Generated Tangle API bindings are required for "
89
+ f"{self.command_name}. Install the default tangle-cli package "
90
+ "with tangle-api, run from a project where local src/tangle_api "
91
+ "shadows site-packages, or install a compatible custom tangle-api package."
91
92
  ) from exc
92
93
  raise
93
94
 
@@ -97,7 +98,7 @@ class LazyTangleApiClient:
97
98
  return self._client
98
99
 
99
100
  def require_available(self) -> None:
100
- """Materialize the client so CLI commands fail before native helper imports."""
101
+ """Materialize the client so CLI commands fail before helper imports."""
101
102
 
102
103
  self._get_client()
103
104
 
@@ -26,11 +26,13 @@ from .api_transport import (
26
26
  log_http_exchange,
27
27
  tangle_verbose_enabled,
28
28
  )
29
- from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse
30
29
  from tangle_api.generated.operations import GeneratedTangleApiOperations
30
+ from . import models as _cli_models
31
31
  from .logger import Logger, _null_logger, get_default_logger
32
32
  from .models import (
33
33
  ComponentInfo,
34
+ ComponentSpec,
35
+ GetExecutionInfoResponse,
34
36
  GraphExecutionState,
35
37
  PipelineRun,
36
38
  RunDetails,
@@ -78,6 +80,11 @@ class TangleApiClient(GeneratedTangleApiOperations):
78
80
  self.session = session or requests.Session()
79
81
  self.include_env_credentials = include_env_credentials
80
82
 
83
+ def _response_model(self, model_name: str, default: Any) -> Any:
84
+ """Use CLI-composed models for generated operation deserialization."""
85
+
86
+ return getattr(_cli_models, model_name, default)
87
+
81
88
  def set_verbose(self, enabled: bool) -> None:
82
89
  """Enable or disable request logging."""
83
90
 
@@ -22,7 +22,7 @@ from .handler import TangleCliHandler
22
22
  from .logger import Logger
23
23
 
24
24
  if TYPE_CHECKING:
25
- from tangle_api.generated.models import ComponentSpec
25
+ from tangle_cli.models import ComponentSpec
26
26
 
27
27
 
28
28
  class ProcessingOutcome(str, Enum):
@@ -171,12 +171,12 @@ class ComponentPublisher(TangleCliHandler):
171
171
  if self.component_spec_model is not None:
172
172
  return self.component_spec_model
173
173
  try:
174
- from tangle_api.generated.models import ComponentSpec
174
+ from tangle_cli.models import ComponentSpec
175
175
  except ModuleNotFoundError as exc:
176
176
  if exc.name == "tangle_api":
177
177
  raise RuntimeError(
178
178
  "Native generated Tangle API bindings are required for component publishing. "
179
- "Install tangle-cli[native] or provide a local tangle_api.generated package."
179
+ "Install the default tangle-cli package with tangle-api, run from a project where local src/tangle_api shadows site-packages, or install a compatible custom tangle-api package."
180
180
  ) from exc
181
181
  raise
182
182
  return ComponentSpec
@@ -64,7 +64,7 @@ class TangleCliHandler:
64
64
  if exc.name == "tangle_api":
65
65
  self.log.error(
66
66
  "❌ Native generated Tangle API bindings are required for Tangle API operations. "
67
- "Install tangle-cli[native] or provide a local tangle_api.generated package."
67
+ "Install the default tangle-cli package with tangle-api, run from a project where local src/tangle_api shadows site-packages, or install a compatible custom tangle-api package."
68
68
  )
69
69
  return None
70
70
  raise
@@ -11,9 +11,56 @@ from __future__ import annotations
11
11
  from dataclasses import asdict, dataclass, field
12
12
  from typing import Any
13
13
 
14
- from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse
14
+ from tangle_api.generated import models as _generated_models
15
15
 
16
16
  from .artifacts import ArtifactComponentQuery, ArtifactInfo
17
+ from . import generated_model_extensions as _cli_model_extensions
18
+
19
+
20
+ def extend_model(public_name: str, base: type[Any], *mixins: type[Any]) -> type[Any]:
21
+ """Compose a generated model with downstream mixins in this namespace."""
22
+
23
+ if not mixins:
24
+ return base
25
+ model = type(public_name, (*mixins, base), {"__module__": __name__})
26
+ model_rebuild = getattr(model, "model_rebuild", None)
27
+ if callable(model_rebuild):
28
+ model_rebuild()
29
+ return model
30
+
31
+
32
+ def compose_models(generated_models_module: Any, *extension_modules: Any) -> dict[str, type[Any]]:
33
+ """Return generated models composed with MODEL_EXTENSIONS mappings.
34
+
35
+ Extension modules declare ``MODEL_EXTENSIONS = {"ModelName": "MixinName"}``.
36
+ Composition happens in this downstream namespace and never mutates
37
+ ``tangle_api.generated.models``.
38
+ """
39
+
40
+ composed: dict[str, type[Any]] = {}
41
+ public_names = getattr(generated_models_module, "__all__", None)
42
+ if public_names is None:
43
+ public_names = [
44
+ name
45
+ for name, value in vars(generated_models_module).items()
46
+ if not name.startswith("_") and isinstance(value, type)
47
+ ]
48
+ for public_name in public_names:
49
+ base = getattr(generated_models_module, public_name)
50
+ mixins: list[type[Any]] = []
51
+ for module in extension_modules:
52
+ mapping = getattr(module, "MODEL_EXTENSIONS", {})
53
+ if not isinstance(mapping, dict):
54
+ continue
55
+ mixin_name = mapping.get(public_name)
56
+ if mixin_name:
57
+ mixins.append(getattr(module, mixin_name))
58
+ composed[public_name] = extend_model(public_name, base, *mixins)
59
+ return composed
60
+
61
+
62
+ _COMPOSED_MODELS = compose_models(_generated_models, _cli_model_extensions)
63
+ globals().update(_COMPOSED_MODELS)
17
64
 
18
65
 
19
66
  # ---- Execution / Run dataclasses -------------------------------------------
@@ -296,10 +343,9 @@ class SecretInfo:
296
343
  # ---- Components ------------------------------------------------------------
297
344
 
298
345
 
299
- # ``ComponentSpec`` is generated from OpenAPI and extended in
300
- # ``tangle_cli.generated_model_extensions.ComponentSpecExtensions``. Re-export
301
- # it from this module for compatibility with callers that import domain models
302
- # from ``tangle_cli.models``.
346
+ # ``ComponentSpec`` is generated from OpenAPI and extended at import time in
347
+ # this module. Re-export it for compatibility with callers that import domain
348
+ # models from ``tangle_cli.models``.
303
349
 
304
350
 
305
351
  @dataclass
@@ -22,9 +22,7 @@ import sys
22
22
  import tempfile
23
23
  import urllib.parse
24
24
  import urllib.request
25
- from collections import Counter
26
25
  from collections.abc import Sequence
27
- from dataclasses import dataclass
28
26
  from pathlib import Path
29
27
  from typing import Any
30
28
 
@@ -40,7 +38,6 @@ _REPO_ROOT = Path(__file__).resolve().parents[5]
40
38
  _GENERATED_DIR = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" / "generated"
41
39
  DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle"
42
40
  DEFAULT_OPERATIONS_CLASS_NAME = "GeneratedTangleApiOperations"
43
- DEFAULT_MODEL_EXTENSION_MODULE = "tangle_cli.generated_model_extensions"
44
41
  DEFAULT_MODEL_ALIASES: dict[str, tuple[str, ...]] = {
45
42
  "ComponentSpec": (
46
43
  "ComponentSpec-Output",
@@ -373,171 +370,63 @@ def _apply_request_body_schema_overrides(
373
370
  return output
374
371
 
375
372
 
376
- @dataclass(frozen=True)
377
- class _ModelExtensionRef:
378
- """Import reference for one generated model extension class."""
373
+ def generate_runtime() -> str:
374
+ """Generate the small runtime module used by generated Pydantic models."""
379
375
 
380
- module_name: str
381
- class_name: str
382
- alias: str
376
+ return '''"""Runtime helpers for generated Tangle API model packages."""
383
377
 
378
+ from __future__ import annotations
384
379
 
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.
380
+ from typing import Any
389
381
 
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
- """
382
+ from pydantic import BaseModel
394
383
 
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)
384
+ try:
385
+ from pydantic import ConfigDict
386
+ except ImportError: # pragma: no cover - pydantic v1 fallback
387
+ ConfigDict = None # type: ignore[assignment]
401
388
 
402
- include_default = True
403
- if "" in modules:
404
- include_default = False
405
- modules = [module for module in modules if module != ""]
406
389
 
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
390
+ class TangleGeneratedModel(BaseModel):
391
+ """Base for generated response models with dict-like conveniences."""
416
392
 
393
+ if ConfigDict is not None:
394
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
395
+ else: # pragma: no cover - pydantic v1 fallback
396
+ class Config:
397
+ extra = "allow"
398
+ allow_population_by_field_name = True
417
399
 
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
400
+ def get(self, key: str, default: Any = None) -> Any:
401
+ return self.to_dict().get(key, default)
423
402
 
403
+ def __getitem__(self, key: str) -> Any:
404
+ return self.to_dict()[key]
424
405
 
425
- def _model_extension_mapping(module_name: str) -> dict[str, str]:
426
- """Load and validate a MODEL_EXTENSIONS mapping from an extension module."""
406
+ def to_dict(self) -> dict[str, Any]:
407
+ if hasattr(self, "model_dump"):
408
+ return self.model_dump(by_alias=True)
409
+ return self.dict(by_alias=True)
427
410
 
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
411
+ @classmethod
412
+ def from_dict(cls, data: dict[str, Any]) -> Any:
413
+ if hasattr(cls, "model_validate"):
414
+ return cls.model_validate(data)
415
+ return cls.parse_obj(data)
433
416
 
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
417
 
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
418
+ __all__ = ["TangleGeneratedModel"]
419
+ '''
529
420
 
530
421
 
531
422
  def generate_models(
532
423
  schema: dict[str, Any],
533
- model_extension_module: str | Sequence[str] | None = None,
534
424
  model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None,
535
425
  ) -> str:
536
- """Generate Pydantic model classes and apply configured model extensions."""
426
+ """Generate plain/base Pydantic model classes."""
537
427
 
538
428
  raw_schemas = schema.get("components", {}).get("schemas", {}) or {}
539
429
  schemas, _ = _apply_model_aliases(raw_schemas, model_aliases)
540
- extension_refs = _model_extension_refs(model_extension_module)
541
430
  lines: list[str] = [
542
431
  '"""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
432
  "",
@@ -547,26 +436,10 @@ def generate_models(
547
436
  "",
548
437
  "from pydantic import Field",
549
438
  "",
550
- "from tangle_cli.generated_runtime import TangleGeneratedModel",
439
+ "from tangle_api.generated.runtime import TangleGeneratedModel",
551
440
  "",
552
441
  ]
553
442
 
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
443
  exports: list[str] = []
571
444
  for schema_name, schema_def in sorted(schemas.items(), key=lambda item: _class_name(item[0])):
572
445
  class_name = _class_name(schema_name)
@@ -575,9 +448,7 @@ def generate_models(
575
448
  lines.extend([f"{class_name} = Any", ""])
576
449
  continue
577
450
  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):"])
451
+ lines.append(f"class {class_name}(TangleGeneratedModel):")
581
452
  if not properties:
582
453
  lines.append(" pass")
583
454
  else:
@@ -588,13 +459,6 @@ def generate_models(
588
459
  else:
589
460
  lines.append(f" {field_name}: Any = None")
590
461
  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
462
 
599
463
  lines.append(f"__all__ = {exports!r}")
600
464
  lines.append("")
@@ -727,6 +591,11 @@ def generate_operations(
727
591
  " response_model: Any = None,",
728
592
  " ) -> Any: ...",
729
593
  "",
594
+ " def _response_model(self, model_name: str, default: Any) -> Any:",
595
+ " \"\"\"Return the model class used to deserialize a generated response.\"\"\"",
596
+ "",
597
+ " return default",
598
+ "",
730
599
  ])
731
600
 
732
601
  used_methods: set[str] = set()
@@ -742,7 +611,7 @@ def generate_operations(
742
611
  raw_body_override=bool(operation.operation.get("x-tangle-cli-request-body-schema-override")),
743
612
  )
744
613
  response_model = _response_model_name(operation.operation, model_ref_aliases)
745
- response_arg = response_model if response_model else "None"
614
+ response_arg = f"self._response_model({response_model!r}, {response_model})" if response_model else "None"
746
615
  response_annotation = _response_return_annotation(operation.operation, model_ref_aliases)
747
616
  if signature:
748
617
  def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:"
@@ -871,7 +740,6 @@ def generate(
871
740
  generated_dir: str | Path = _GENERATED_DIR,
872
741
  *,
873
742
  operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME,
874
- model_extension_module: str | Sequence[str] | None = None,
875
743
  model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None,
876
744
  request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None = None,
877
745
  ) -> tuple[dict[str, Any], list[Path]]:
@@ -880,6 +748,7 @@ def generate(
880
748
  output_dir.mkdir(parents=True, exist_ok=True)
881
749
  generated_files = [
882
750
  output_dir / "__init__.py",
751
+ output_dir / "runtime.py",
883
752
  output_dir / "models.py",
884
753
  output_dir / "operations.py",
885
754
  ]
@@ -887,15 +756,15 @@ def generate(
887
756
  '"""Generated OpenAPI support modules."""\n',
888
757
  encoding="utf-8",
889
758
  )
890
- generated_files[1].write_text(
759
+ generated_files[1].write_text(generate_runtime(), encoding="utf-8")
760
+ generated_files[2].write_text(
891
761
  generate_models(
892
762
  schema,
893
- model_extension_module=model_extension_module,
894
763
  model_aliases=model_aliases,
895
764
  ),
896
765
  encoding="utf-8",
897
766
  )
898
- generated_files[2].write_text(
767
+ generated_files[3].write_text(
899
768
  generate_operations(
900
769
  schema,
901
770
  operations_class_name=operations_class_name,
@@ -961,19 +830,6 @@ def main(argv: list[str] | None = None) -> None:
961
830
  f"(default: {DEFAULT_OPERATIONS_CLASS_NAME})."
962
831
  ),
963
832
  )
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
833
  parser.add_argument(
978
834
  "--model-alias",
979
835
  action="append",
@@ -1031,7 +887,6 @@ def main(argv: list[str] | None = None) -> None:
1031
887
  args = parser.parse_args(argv)
1032
888
  try:
1033
889
  _validate_class_name(args.operations_class_name)
1034
- _model_extension_refs(args.model_extension_module)
1035
890
  _model_alias_mapping(args.model_alias)
1036
891
  request_body_schema_overrides = _request_body_schema_mapping(args.request_body_schema)
1037
892
  request_body_schema_overrides.update(_request_body_schema_file_mapping(args.request_body_schema_file))
@@ -1073,7 +928,6 @@ def main(argv: list[str] | None = None) -> None:
1073
928
  openapi_path,
1074
929
  args.out,
1075
930
  operations_class_name=args.operations_class_name,
1076
- model_extension_module=args.model_extension_module,
1077
931
  model_aliases=args.model_alias,
1078
932
  request_body_schemas=request_body_schema_overrides,
1079
933
  )
@@ -49,9 +49,10 @@ def _load_default_openapi_schema() -> dict[str, Any]:
49
49
  last_error = exc
50
50
  if schema_text is None:
51
51
  raise FileNotFoundError(
52
- "Default OpenAPI snapshot not found. Install tangle-api, run from a "
53
- "source checkout with packages/tangle-api/src/tangle_api/schema/openapi.json, "
54
- "or pass --openapi PATH explicitly."
52
+ "Default OpenAPI snapshot not found. Install the default or a compatible "
53
+ "custom tangle-api package, run from a source checkout with "
54
+ "packages/tangle-api/src/tangle_api/schema/openapi.json, or pass "
55
+ "--openapi PATH explicitly."
55
56
  ) from last_error
56
57
 
57
58
  schema = json.loads(schema_text)
@@ -28,9 +28,9 @@ _CREATED_AT_WIDTH = 16
28
28
  class PageChunk:
29
29
  """Metadata for a single page of search results.
30
30
 
31
- Defined locally to keep this module importable without the native
32
- ``tangle-api`` extra; ``tangle_cli.models`` re-exports an equivalent
33
- dataclass when native models are available.
31
+ Defined locally to keep this module importable without loading generated
32
+ ``tangle-api`` bindings; ``tangle_cli.models`` re-exports an equivalent
33
+ dataclass when generated models are imported.
34
34
  """
35
35
 
36
36
  rows: list[dict[str, Any]]
@@ -1,4 +1,4 @@
1
- """Native-free quickstart text for the root ``tangle`` CLI."""
1
+ """Quickstart text for the root ``tangle`` CLI."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -7,7 +7,7 @@ from textwrap import dedent
7
7
  from cyclopts import App
8
8
 
9
9
 
10
- app = App(name="quickstart", help="Print a concise native-free guide to the Tangle CLI.")
10
+ app = App(name="quickstart", help="Print a concise guide to the Tangle CLI.")
11
11
 
12
12
 
13
13
  QUICKSTART_TEXT = dedent(
@@ -76,16 +76,15 @@ QUICKSTART_TEXT = dedent(
76
76
  dynamic schema discovery, codegen, logging, hydrator/resolver logic, and
77
77
  extension hooks.
78
78
 
79
- tangle_api is the generated/native package: checked-in Pydantic models,
80
- endpoint operation methods, and the official OpenAPI snapshot. Local-only
81
- SDK commands and this quickstart do not need it. Static API-backed commands
82
- need tangle-cli[native] or an equivalent local tangle_api.generated package.
79
+ tangle_api is the generated package: checked-in Pydantic models,
80
+ endpoint operation methods, and the official OpenAPI snapshot. Public
81
+ tangle-cli installs include the matching tangle-api package by default.
82
+ Codegen/custom API projects can still generate a local src/tangle_api
83
+ package that shadows site-packages, or provide a compatible private
84
+ distribution named tangle-api for their environment.
83
85
 
84
- Generated model extensions use private generated bases plus stable public
85
- subclasses, e.g. ComponentSpec(ComponentSpecExtensions,
86
- _ComponentSpecGenerated). Extension bases are left of the generated base in
87
- the MRO, and downstream --model-extension-module values can add/override
88
- behavior while preserving generated fields and stable names.
86
+ Generated model extensions are composed at runtime in downstream namespaces
87
+ such as tangle_cli.models, leaving tangle_api generated models plain/leaf.
89
88
 
90
89
  Discover more
91
90
  -------------
@@ -105,6 +104,6 @@ QUICKSTART_TEXT = dedent(
105
104
 
106
105
  @app.default
107
106
  def quickstart() -> None:
108
- """Print a concise native-free guide to the Tangle CLI."""
107
+ """Print a concise guide to the Tangle CLI."""
109
108
 
110
109
  print(QUICKSTART_TEXT)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tangle-cli"
3
- version = "0.0.1a1"
3
+ version = "0.0.1a3"
4
4
  description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "pydantic>=2.0",
19
19
  "pyyaml>=6.0",
20
20
  "requests>=2.32.0",
21
+ "tangle-api==0.0.1a3",
21
22
  "tomli>=2.0; python_version < '3.11'",
22
23
  ]
23
24
 
@@ -28,10 +29,12 @@ Repository = "https://github.com/TangleML/tangle-cli"
28
29
  Issues = "https://github.com/TangleML/tangle-cli/issues"
29
30
 
30
31
  [project.optional-dependencies]
31
- native = ["tangle-api==0.1.0"]
32
+ # Compatibility alias: tangle-api is now part of the default public install.
33
+ native = []
32
34
 
33
35
  [project.scripts]
34
36
  tangle = "tangle_cli.cli:main"
37
+ tangle-cli = "tangle_cli.cli:main"
35
38
 
36
39
  [build-system]
37
40
  requires = ["uv_build>=0.11.2,<0.12.0"]
@@ -1,43 +0,0 @@
1
- """Runtime helpers shared by generated Tangle API model packages."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- from pydantic import BaseModel
8
-
9
- try:
10
- from pydantic import ConfigDict
11
- except ImportError: # pragma: no cover - pydantic v1 fallback
12
- ConfigDict = None # type: ignore[assignment]
13
-
14
-
15
- class TangleGeneratedModel(BaseModel):
16
- """Base for generated response models with dict-like conveniences."""
17
-
18
- if ConfigDict is not None:
19
- model_config = ConfigDict(extra="allow", populate_by_name=True)
20
- else: # pragma: no cover - pydantic v1 fallback
21
- class Config:
22
- extra = "allow"
23
- allow_population_by_field_name = True
24
-
25
- def get(self, key: str, default: Any = None) -> Any:
26
- return self.to_dict().get(key, default)
27
-
28
- def __getitem__(self, key: str) -> Any:
29
- return self.to_dict()[key]
30
-
31
- def to_dict(self) -> dict[str, Any]:
32
- if hasattr(self, "model_dump"):
33
- return self.model_dump(by_alias=True)
34
- return self.dict(by_alias=True)
35
-
36
- @classmethod
37
- def from_dict(cls, data: dict[str, Any]) -> Any:
38
- if hasattr(cls, "model_validate"):
39
- return cls.model_validate(data)
40
- return cls.parse_obj(data)
41
-
42
-
43
- __all__ = ["TangleGeneratedModel"]