diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44c3775..a7edf13 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,12 +30,12 @@ jobs: # which distribution is installed for tangle-cli smoke tests. - name: Smoke test tangle-cli wheel run: | - uv run --isolated --no-project --with dist/tangle_cli-*.whl tangle version - uv run --isolated --no-project --with dist/tangle_cli-*.whl tangle-cli version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.whl tangle version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.whl tangle-cli version - name: Smoke test tangle-cli source distribution run: | - uv run --isolated --no-project --with dist/tangle_cli-*.tar.gz tangle version - uv run --isolated --no-project --with dist/tangle_cli-*.tar.gz tangle-cli version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.tar.gz tangle version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.tar.gz tangle-cli version - name: Smoke test tangle-api wheel run: | uv run --isolated --no-project \ @@ -48,7 +48,7 @@ jobs: --with dist/tangle_cli-*.tar.gz \ --with dist/tangle_api-*.tar.gz \ python -c "import importlib.metadata as m; import tangle_api.generated.models; import tangle_api.schema; assert m.version('tangle-api') == m.version('tangle-cli')" - - name: Smoke test native extra resolution from local dist + - name: Smoke test native extra compatibility alias from local dist run: | cli_wheel="$(echo dist/tangle_cli-*.whl)" uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version diff --git a/README.md b/README.md index 7dd3b8c..dcd0396 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ By default `tangle api` uses `--schema-source auto`, which means official static `tangle sdk` commands are hand-written workflows. They can be: -- **local-only**: no generated/native API bindings required, e.g. pipeline validation/layout and component generation; +- **local-only**: no generated API bindings required, e.g. pipeline validation/layout and component generation; - **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. Current SDK groups include: @@ -86,22 +86,22 @@ Use `--log-type none` for quiet machine-readable runs, and `--log-type file` to The repository contains two Python import packages with different responsibilities: - `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. -- `tangle_api` is generated/native. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot. +- `tangle_api` is generated/static. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot. -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: +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: ```bash -pip install 'tangle-cli[native]' +pip install tangle-cli ``` -In this workspace, `uv` installs the workspace `tangle-api` package for development and tests: +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: ```bash uv run tangle api --help uv run tangle sdk pipelines validate pipeline.yaml ``` -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. +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. ## Quick command examples @@ -204,9 +204,9 @@ uv run tangle api reset-cache --base-url https://api.example Schema source modes are: -- `--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. -- `--schema-source official`: only the checked-in official static schema. Requires the native `tangle-api` package. -- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. Does not require the native package. +- `--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. +- `--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). +- `--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. For resource help, put `--schema-source` on the resource group: @@ -306,7 +306,7 @@ existing = client.find_existing_components( `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. -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`. +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`. ## Codegen/autogen from OpenAPI @@ -337,6 +337,17 @@ uv run python -m tangle_cli.openapi.codegen \ --out src/tangle_api/generated ``` +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: + +```bash +uv run python -m tangle_cli.openapi.codegen \ + --openapi-url https://api.example/openapi.json \ + --openapi src/tangle_api/schema/openapi.json \ + --out src/tangle_api/generated +``` + +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. + Generate from a backend checkout explicitly: ```bash @@ -347,48 +358,29 @@ uv run --group codegen python -m tangle_cli.openapi.codegen \ Important codegen options: -- `--out`: directory that receives `__init__.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`. +- `--out`: directory that receives `__init__.py`, `runtime.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`. - `--operations-class-name`: generated operations mixin class name. Defaults to `GeneratedTangleApiOperations`. -- `--model-extension-module`: importable module with `MODEL_EXTENSIONS`; repeat to compose modules. - `--model-alias`: expose a stable public model name from one or more source schema names, e.g. `ComponentSpec=ComponentSpecOutput,ComponentSpecInput`. - `--request-body-schema` / `--request-body-schema-file`: override a specific operation's JSON request-body schema without mutating the fetched OpenAPI document. At runtime, more `tangle api ...` commands become available in two ways: -1. Static codegen: regenerate and install/provide a `tangle_api.generated` package for the schema. +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`. 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. -## Generated model extension pattern +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. -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: +## Runtime generated model extension pattern + +`tangle_api.generated.models` is a leaf package and codegen emits plain generated Pydantic models directly: ```python -class _ComponentSpecGenerated(TangleGeneratedModel): +class ComponentSpec(TangleGeneratedModel): name: Any = None # generated OpenAPI fields... - -class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): - pass -``` - -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. - -Extension bases are placed to the **left** of the generated base: - -```python -class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): - pass -``` - -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. - -The built-in default extension module is: - -```text -tangle_cli.generated_model_extensions ``` -It defines: +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`: ```python MODEL_EXTENSIONS = { @@ -398,42 +390,11 @@ MODEL_EXTENSIONS = { } ``` -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. - -Downstream projects can layer their own extensions: - -```python -# my_project/tangle_model_extensions.py -class MyComponentSpecExtensions: - @property - def owning_team(self) -> str | None: - return (self.metadata or {}).get("annotations", {}).get("team") - -MODEL_EXTENSIONS = { - "ComponentSpec": "MyComponentSpecExtensions", -} -``` - -```bash -uv run python -m tangle_cli.openapi.codegen \ - --openapi-url https://api.example/openapi.json \ - --out src/tangle_api/generated \ - --model-extension-module my_project.tangle_model_extensions -``` - -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. - -Pass an empty string to disable built-in default extensions: - -```bash -uv run python -m tangle_cli.openapi.codegen \ - --from-snapshot \ - --model-extension-module "" -``` +`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. -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`. +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`. -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. +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`. ## Extending SDK behavior diff --git a/packages/tangle-api/README.md b/packages/tangle-api/README.md index f8c2449..20f7c7e 100644 --- a/packages/tangle-api/README.md +++ b/packages/tangle-api/README.md @@ -1,3 +1,5 @@ # tangle-api -Checked-in generated Tangle API models, operation proxies, and schema snapshot used by `tangle-cli[native]`. +Checked-in generated Tangle API models, operation proxies, and schema snapshot used by the default `tangle-cli` install. + +This package is intentionally a leaf package: it depends on Pydantic, but not on `tangle-cli`. Custom API consumers can provide their own compatible distribution named `tangle-api` or a project-local `src/tangle_api` package that shadows the official package in that project environment. diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index 5f1f3ec..ff045ad 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-api" -version = "0.0.1a2" +version = "0.0.1a3" description = "Checked-in generated Tangle API models and operation proxies" readme = "README.md" authors = [ @@ -10,7 +10,6 @@ authors = [ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", - "tangle-cli==0.0.1a2", ] [build-system] diff --git a/packages/tangle-api/src/tangle_api/generated/models.py b/packages/tangle-api/src/tangle_api/generated/models.py index b6cbe7d..af3849c 100644 --- a/packages/tangle-api/src/tangle_api/generated/models.py +++ b/packages/tangle-api/src/tangle_api/generated/models.py @@ -9,11 +9,9 @@ from pydantic import Field -from tangle_cli.generated_runtime import TangleGeneratedModel +from tangle_api.generated.runtime import TangleGeneratedModel -from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetExecutionInfoResponseExtensions, GetGraphExecutionStateResponseExtensions - -class _ArtifactDataGenerated(TangleGeneratedModel): +class ArtifactData(TangleGeneratedModel): created_at: Any = None deleted_at: Any = None extra_data: Any = None @@ -23,25 +21,16 @@ class _ArtifactDataGenerated(TangleGeneratedModel): uri: Any = None value: Any = None -class ArtifactData(_ArtifactDataGenerated): - pass - -class _ArtifactDataResponseGenerated(TangleGeneratedModel): +class ArtifactDataResponse(TangleGeneratedModel): is_dir: Any = None total_size: Any = None uri: Any = None value: Any = None -class ArtifactDataResponse(_ArtifactDataResponseGenerated): - pass - -class _ArtifactNodeIdResponseGenerated(TangleGeneratedModel): +class ArtifactNodeIdResponse(TangleGeneratedModel): id: Any = None -class ArtifactNodeIdResponse(_ArtifactNodeIdResponseGenerated): - pass - -class _ArtifactNodeResponseGenerated(TangleGeneratedModel): +class ArtifactNodeResponse(TangleGeneratedModel): artifact_data: Any = None id: Any = None producer_execution_id: Any = None @@ -49,59 +38,35 @@ class _ArtifactNodeResponseGenerated(TangleGeneratedModel): type_name: Any = None type_properties: Any = None -class ArtifactNodeResponse(_ArtifactNodeResponseGenerated): - pass - -class _BodyCreateApiPipelineRunsPostGenerated(TangleGeneratedModel): +class BodyCreateApiPipelineRunsPost(TangleGeneratedModel): annotations: Any = None components: Any = None root_task: Any = None -class BodyCreateApiPipelineRunsPost(_BodyCreateApiPipelineRunsPostGenerated): - pass - -class _BodyCreateSecretApiSecretsPostGenerated(TangleGeneratedModel): +class BodyCreateSecretApiSecretsPost(TangleGeneratedModel): secret_value: Any = None -class BodyCreateSecretApiSecretsPost(_BodyCreateSecretApiSecretsPostGenerated): - pass - -class _BodySetSettingsApiUsersMeSettingsPatchGenerated(TangleGeneratedModel): +class BodySetSettingsApiUsersMeSettingsPatch(TangleGeneratedModel): settings: Any = None -class BodySetSettingsApiUsersMeSettingsPatch(_BodySetSettingsApiUsersMeSettingsPatchGenerated): - pass - -class _BodyUpdateSecretApiSecretsSecretNamePutGenerated(TangleGeneratedModel): +class BodyUpdateSecretApiSecretsSecretNamePut(TangleGeneratedModel): secret_value: Any = None -class BodyUpdateSecretApiSecretsSecretNamePut(_BodyUpdateSecretApiSecretsSecretNamePutGenerated): - pass - -class _CachingStrategySpecGenerated(TangleGeneratedModel): +class CachingStrategySpec(TangleGeneratedModel): maxcachestaleness: Any = Field(default=None, alias='maxCacheStaleness') -class CachingStrategySpec(_CachingStrategySpecGenerated): - pass - -class _ComponentLibraryGenerated(TangleGeneratedModel): +class ComponentLibrary(TangleGeneratedModel): annotations: Any = None name: Any = None root_folder: Any = None -class ComponentLibrary(_ComponentLibraryGenerated): - pass - -class _ComponentLibraryFolderGenerated(TangleGeneratedModel): +class ComponentLibraryFolder(TangleGeneratedModel): annotations: Any = None components: Any = None folders: Any = None name: Any = None -class ComponentLibraryFolder(_ComponentLibraryFolderGenerated): - pass - -class _ComponentLibraryResponseGenerated(TangleGeneratedModel): +class ComponentLibraryResponse(TangleGeneratedModel): annotations: Any = None component_count: Any = None created_at: Any = None @@ -112,10 +77,7 @@ class _ComponentLibraryResponseGenerated(TangleGeneratedModel): root_folder: Any = None updated_at: Any = None -class ComponentLibraryResponse(_ComponentLibraryResponseGenerated): - pass - -class _ComponentReferenceGenerated(TangleGeneratedModel): +class ComponentReference(TangleGeneratedModel): digest: Any = None name: Any = None spec: Any = None @@ -123,17 +85,11 @@ class _ComponentReferenceGenerated(TangleGeneratedModel): text: Any = None url: Any = None -class ComponentReference(_ComponentReferenceGenerated): - pass - -class _ComponentResponseGenerated(TangleGeneratedModel): +class ComponentResponse(TangleGeneratedModel): digest: Any = None text: Any = None -class ComponentResponse(_ComponentResponseGenerated): - pass - -class _ComponentSpecGenerated(TangleGeneratedModel): +class ComponentSpec(TangleGeneratedModel): description: Any = None implementation: Any = None inputs: Any = None @@ -141,82 +97,49 @@ class _ComponentSpecGenerated(TangleGeneratedModel): name: Any = None outputs: Any = None -class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): - pass - -class _ConcatPlaceholderGenerated(TangleGeneratedModel): +class ConcatPlaceholder(TangleGeneratedModel): concat: Any = None -class ConcatPlaceholder(_ConcatPlaceholderGenerated): - pass - ContainerExecutionStatus = Any -class _ContainerImplementationGenerated(TangleGeneratedModel): +class ContainerImplementation(TangleGeneratedModel): container: Any = None -class ContainerImplementation(_ContainerImplementationGenerated): - pass - -class _ContainerSpecGenerated(TangleGeneratedModel): +class ContainerSpec(TangleGeneratedModel): args: Any = None command: Any = None env: Any = None image: Any = None -class ContainerSpec(_ContainerSpecGenerated): - pass - -class _DynamicDataArgumentGenerated(TangleGeneratedModel): +class DynamicDataArgument(TangleGeneratedModel): dynamicdata: Any = Field(default=None, alias='dynamicData') -class DynamicDataArgument(_DynamicDataArgumentGenerated): - pass - -class _ExecutionNodeReferenceGenerated(TangleGeneratedModel): +class ExecutionNodeReference(TangleGeneratedModel): execution_node_id: Any = None pipeline_run_id: Any = None -class ExecutionNodeReference(_ExecutionNodeReferenceGenerated): - pass - -class _ExecutionOptionsSpecGenerated(TangleGeneratedModel): +class ExecutionOptionsSpec(TangleGeneratedModel): cachingstrategy: Any = Field(default=None, alias='cachingStrategy') retrystrategy: Any = Field(default=None, alias='retryStrategy') -class ExecutionOptionsSpec(_ExecutionOptionsSpecGenerated): - pass - -class _ExecutionStatusSummaryGenerated(TangleGeneratedModel): +class ExecutionStatusSummary(TangleGeneratedModel): ended_executions: Any = None has_ended: Any = None total_executions: Any = None -class ExecutionStatusSummary(_ExecutionStatusSummaryGenerated): - pass - -class _GetArtifactInfoResponseGenerated(TangleGeneratedModel): +class GetArtifactInfoResponse(TangleGeneratedModel): artifact_data: Any = None id: Any = None -class GetArtifactInfoResponse(_GetArtifactInfoResponseGenerated): - pass - -class _GetArtifactSignedUrlResponseGenerated(TangleGeneratedModel): +class GetArtifactSignedUrlResponse(TangleGeneratedModel): signed_url: Any = None -class GetArtifactSignedUrlResponse(_GetArtifactSignedUrlResponseGenerated): - pass - -class _GetContainerExecutionLogResponseGenerated(TangleGeneratedModel): +class GetContainerExecutionLogResponse(TangleGeneratedModel): log_text: Any = None orchestration_error_message: Any = None system_error_exception_full: Any = None -class GetContainerExecutionLogResponse(_GetContainerExecutionLogResponseGenerated): - pass - -class _GetContainerExecutionStateResponseGenerated(TangleGeneratedModel): +class GetContainerExecutionStateResponse(TangleGeneratedModel): debug_info: Any = None ended_at: Any = None execution_nodes_linked_to_same_container_execution: Any = None @@ -224,17 +147,11 @@ class _GetContainerExecutionStateResponseGenerated(TangleGeneratedModel): started_at: Any = None status: Any = None -class GetContainerExecutionStateResponse(_GetContainerExecutionStateResponseGenerated): - pass - -class _GetExecutionArtifactsResponseGenerated(TangleGeneratedModel): +class GetExecutionArtifactsResponse(TangleGeneratedModel): input_artifacts: Any = None output_artifacts: Any = None -class GetExecutionArtifactsResponse(_GetExecutionArtifactsResponseGenerated): - pass - -class _GetExecutionInfoResponseGenerated(TangleGeneratedModel): +class GetExecutionInfoResponse(TangleGeneratedModel): child_task_execution_ids: Any = None id: Any = None input_artifacts: Any = None @@ -243,76 +160,43 @@ class _GetExecutionInfoResponseGenerated(TangleGeneratedModel): pipeline_run_id: Any = None task_spec: Any = None -class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, _GetExecutionInfoResponseGenerated): - pass - -class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel): +class GetGraphExecutionStateResponse(TangleGeneratedModel): child_execution_status_stats: Any = None child_execution_status_summary: Any = None -class GetGraphExecutionStateResponse(GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated): - pass - -class _GetUserResponseGenerated(TangleGeneratedModel): +class GetUserResponse(TangleGeneratedModel): id: Any = None permissions: Any = None -class GetUserResponse(_GetUserResponseGenerated): - pass - -class _GraphImplementationGenerated(TangleGeneratedModel): +class GraphImplementation(TangleGeneratedModel): graph: Any = None -class GraphImplementation(_GraphImplementationGenerated): - pass - -class _GraphInputArgumentGenerated(TangleGeneratedModel): +class GraphInputArgument(TangleGeneratedModel): graphinput: Any = Field(default=None, alias='graphInput') -class GraphInputArgument(_GraphInputArgumentGenerated): - pass - -class _GraphInputReferenceGenerated(TangleGeneratedModel): +class GraphInputReference(TangleGeneratedModel): inputname: Any = Field(default=None, alias='inputName') type: Any = None -class GraphInputReference(_GraphInputReferenceGenerated): - pass - -class _GraphSpecGenerated(TangleGeneratedModel): +class GraphSpec(TangleGeneratedModel): outputvalues: Any = Field(default=None, alias='outputValues') tasks: Any = None -class GraphSpec(_GraphSpecGenerated): - pass - -class _HTTPValidationErrorGenerated(TangleGeneratedModel): +class HTTPValidationError(TangleGeneratedModel): detail: Any = None -class HTTPValidationError(_HTTPValidationErrorGenerated): - pass - -class _IfPlaceholderGenerated(TangleGeneratedModel): +class IfPlaceholder(TangleGeneratedModel): if_: Any = Field(default=None, alias='if') -class IfPlaceholder(_IfPlaceholderGenerated): - pass - -class _IfPlaceholderStructureGenerated(TangleGeneratedModel): +class IfPlaceholderStructure(TangleGeneratedModel): cond: Any = None else_: Any = Field(default=None, alias='else') then: Any = None -class IfPlaceholderStructure(_IfPlaceholderStructureGenerated): - pass - -class _InputPathPlaceholderGenerated(TangleGeneratedModel): +class InputPathPlaceholder(TangleGeneratedModel): inputpath: Any = Field(default=None, alias='inputPath') -class InputPathPlaceholder(_InputPathPlaceholderGenerated): - pass - -class _InputSpecGenerated(TangleGeneratedModel): +class InputSpec(TangleGeneratedModel): annotations: Any = None default: Any = None description: Any = None @@ -320,69 +204,39 @@ class _InputSpecGenerated(TangleGeneratedModel): optional: Any = None type: Any = None -class InputSpec(_InputSpecGenerated): - pass - -class _InputValuePlaceholderGenerated(TangleGeneratedModel): +class InputValuePlaceholder(TangleGeneratedModel): inputvalue: Any = Field(default=None, alias='inputValue') -class InputValuePlaceholder(_InputValuePlaceholderGenerated): - pass - -class _IsPresentPlaceholderGenerated(TangleGeneratedModel): +class IsPresentPlaceholder(TangleGeneratedModel): ispresent: Any = Field(default=None, alias='isPresent') -class IsPresentPlaceholder(_IsPresentPlaceholderGenerated): - pass - -class _ListComponentLibrariesResponseGenerated(TangleGeneratedModel): +class ListComponentLibrariesResponse(TangleGeneratedModel): component_libraries: Any = None -class ListComponentLibrariesResponse(_ListComponentLibrariesResponseGenerated): - pass - -class _ListPipelineJobsResponseGenerated(TangleGeneratedModel): +class ListPipelineJobsResponse(TangleGeneratedModel): next_page_token: Any = None pipeline_runs: Any = None -class ListPipelineJobsResponse(_ListPipelineJobsResponseGenerated): - pass - -class _ListPublishedComponentsResponseGenerated(TangleGeneratedModel): +class ListPublishedComponentsResponse(TangleGeneratedModel): published_components: Any = None -class ListPublishedComponentsResponse(_ListPublishedComponentsResponseGenerated): - pass - -class _ListSecretsResponseGenerated(TangleGeneratedModel): +class ListSecretsResponse(TangleGeneratedModel): secrets: Any = None -class ListSecretsResponse(_ListSecretsResponseGenerated): - pass - -class _MetadataSpecGenerated(TangleGeneratedModel): +class MetadataSpec(TangleGeneratedModel): annotations: Any = None labels: Any = None -class MetadataSpec(_MetadataSpecGenerated): - pass - -class _OutputPathPlaceholderGenerated(TangleGeneratedModel): +class OutputPathPlaceholder(TangleGeneratedModel): outputpath: Any = Field(default=None, alias='outputPath') -class OutputPathPlaceholder(_OutputPathPlaceholderGenerated): - pass - -class _OutputSpecGenerated(TangleGeneratedModel): +class OutputSpec(TangleGeneratedModel): annotations: Any = None description: Any = None name: Any = None type: Any = None -class OutputSpec(_OutputSpecGenerated): - pass - -class _PipelineRunResponseGenerated(TangleGeneratedModel): +class PipelineRunResponse(TangleGeneratedModel): annotations: Any = None created_at: Any = None created_by: Any = None @@ -392,10 +246,7 @@ class _PipelineRunResponseGenerated(TangleGeneratedModel): pipeline_name: Any = None root_execution_id: Any = None -class PipelineRunResponse(_PipelineRunResponseGenerated): - pass - -class _PublishedComponentResponseGenerated(TangleGeneratedModel): +class PublishedComponentResponse(TangleGeneratedModel): deprecated: Any = None digest: Any = None name: Any = None @@ -403,68 +254,41 @@ class _PublishedComponentResponseGenerated(TangleGeneratedModel): superseded_by: Any = None url: Any = None -class PublishedComponentResponse(_PublishedComponentResponseGenerated): - pass - -class _RetryStrategySpecGenerated(TangleGeneratedModel): +class RetryStrategySpec(TangleGeneratedModel): maxretries: Any = Field(default=None, alias='maxRetries') -class RetryStrategySpec(_RetryStrategySpecGenerated): - pass - -class _SecretInfoResponseGenerated(TangleGeneratedModel): +class SecretInfoResponse(TangleGeneratedModel): created_at: Any = None description: Any = None expires_at: Any = None secret_name: Any = None updated_at: Any = None -class SecretInfoResponse(_SecretInfoResponseGenerated): - pass - -class _TaskOutputArgumentGenerated(TangleGeneratedModel): +class TaskOutputArgument(TangleGeneratedModel): taskoutput: Any = Field(default=None, alias='taskOutput') -class TaskOutputArgument(_TaskOutputArgumentGenerated): - pass - -class _TaskOutputReferenceGenerated(TangleGeneratedModel): +class TaskOutputReference(TangleGeneratedModel): outputname: Any = Field(default=None, alias='outputName') taskid: Any = Field(default=None, alias='taskId') -class TaskOutputReference(_TaskOutputReferenceGenerated): - pass - -class _TaskSpecGenerated(TangleGeneratedModel): +class TaskSpec(TangleGeneratedModel): annotations: Any = None arguments: Any = None componentref: Any = Field(default=None, alias='componentRef') executionoptions: Any = Field(default=None, alias='executionOptions') isenabled: Any = Field(default=None, alias='isEnabled') -class TaskSpec(_TaskSpecGenerated): - pass - -class _UserComponentLibraryPinsResponseGenerated(TangleGeneratedModel): +class UserComponentLibraryPinsResponse(TangleGeneratedModel): component_library_ids: Any = None -class UserComponentLibraryPinsResponse(_UserComponentLibraryPinsResponseGenerated): - pass - -class _UserSettingsResponseGenerated(TangleGeneratedModel): +class UserSettingsResponse(TangleGeneratedModel): settings: Any = None -class UserSettingsResponse(_UserSettingsResponseGenerated): - pass - -class _ValidationErrorGenerated(TangleGeneratedModel): +class ValidationError(TangleGeneratedModel): ctx: Any = None input: Any = None loc: Any = None msg: Any = None type: Any = None -class ValidationError(_ValidationErrorGenerated): - pass - __all__ = ['ArtifactData', 'ArtifactDataResponse', 'ArtifactNodeIdResponse', 'ArtifactNodeResponse', 'BodyCreateApiPipelineRunsPost', 'BodyCreateSecretApiSecretsPost', 'BodySetSettingsApiUsersMeSettingsPatch', 'BodyUpdateSecretApiSecretsSecretNamePut', 'CachingStrategySpec', 'ComponentLibrary', 'ComponentLibraryFolder', 'ComponentLibraryResponse', 'ComponentReference', 'ComponentResponse', 'ComponentSpec', 'ConcatPlaceholder', 'ContainerExecutionStatus', 'ContainerImplementation', 'ContainerSpec', 'DynamicDataArgument', 'ExecutionNodeReference', 'ExecutionOptionsSpec', 'ExecutionStatusSummary', 'GetArtifactInfoResponse', 'GetArtifactSignedUrlResponse', 'GetContainerExecutionLogResponse', 'GetContainerExecutionStateResponse', 'GetExecutionArtifactsResponse', 'GetExecutionInfoResponse', 'GetGraphExecutionStateResponse', 'GetUserResponse', 'GraphImplementation', 'GraphInputArgument', 'GraphInputReference', 'GraphSpec', 'HTTPValidationError', 'IfPlaceholder', 'IfPlaceholderStructure', 'InputPathPlaceholder', 'InputSpec', 'InputValuePlaceholder', 'IsPresentPlaceholder', 'ListComponentLibrariesResponse', 'ListPipelineJobsResponse', 'ListPublishedComponentsResponse', 'ListSecretsResponse', 'MetadataSpec', 'OutputPathPlaceholder', 'OutputSpec', 'PipelineRunResponse', 'PublishedComponentResponse', 'RetryStrategySpec', 'SecretInfoResponse', 'TaskOutputArgument', 'TaskOutputReference', 'TaskSpec', 'UserComponentLibraryPinsResponse', 'UserSettingsResponse', 'ValidationError'] diff --git a/packages/tangle-api/src/tangle_api/generated/operations.py b/packages/tangle-api/src/tangle_api/generated/operations.py index c946eb9..e0694c7 100644 --- a/packages/tangle-api/src/tangle_api/generated/operations.py +++ b/packages/tangle-api/src/tangle_api/generated/operations.py @@ -26,6 +26,11 @@ def _request_json( response_model: Any = None, ) -> Any: ... + def _response_model(self, model_name: str, default: Any) -> Any: + """Return the model class used to deserialize a generated response.""" + + return default + def admin_execution_node_status(self, id: Any, status: Any) -> None: return self._request_json( 'PUT', @@ -63,7 +68,7 @@ def artifacts_get(self, id: Any) -> GetArtifactInfoResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetArtifactInfoResponse, + response_model=self._response_model('GetArtifactInfoResponse', GetArtifactInfoResponse), ) def artifacts_signed_artifact_url(self, id: Any) -> GetArtifactSignedUrlResponse: @@ -73,7 +78,7 @@ def artifacts_signed_artifact_url(self, id: Any) -> GetArtifactSignedUrlResponse path_params={'id': id}, params=None, json_data=None, - response_model=GetArtifactSignedUrlResponse, + response_model=self._response_model('GetArtifactSignedUrlResponse', GetArtifactSignedUrlResponse), ) def component_libraries_list(self, name_substring: Any = None) -> ListComponentLibrariesResponse: @@ -83,7 +88,7 @@ def component_libraries_list(self, name_substring: Any = None) -> ListComponentL path_params=None, params={'name_substring': name_substring}, json_data=None, - response_model=ListComponentLibrariesResponse, + response_model=self._response_model('ListComponentLibrariesResponse', ListComponentLibrariesResponse), ) def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: @@ -93,7 +98,7 @@ def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> path_params=None, params={'hide_from_search': hide_from_search}, json_data={'name': name}, - response_model=ComponentLibraryResponse, + response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) def component_libraries_get(self, id: Any, include_component_texts: Any = None) -> ComponentLibraryResponse: @@ -103,7 +108,7 @@ def component_libraries_get(self, id: Any, include_component_texts: Any = None) path_params={'id': id}, params={'include_component_texts': include_component_texts}, json_data=None, - response_model=ComponentLibraryResponse, + response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: @@ -113,7 +118,7 @@ def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = path_params={'id': id}, params={'hide_from_search': hide_from_search}, json_data={'name': name}, - response_model=ComponentLibraryResponse, + response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) def component_library_pins_me(self) -> UserComponentLibraryPinsResponse: @@ -123,7 +128,7 @@ def component_library_pins_me(self) -> UserComponentLibraryPinsResponse: path_params=None, params=None, json_data=None, - response_model=UserComponentLibraryPinsResponse, + response_model=self._response_model('UserComponentLibraryPinsResponse', UserComponentLibraryPinsResponse), ) def component_library_pins_put_me(self, body: Any = None) -> None: @@ -143,7 +148,7 @@ def components_get(self, digest: Any) -> ComponentResponse: path_params={'digest': digest}, params=None, json_data=None, - response_model=ComponentResponse, + response_model=self._response_model('ComponentResponse', ComponentResponse), ) def executions_artifacts(self, id: Any) -> GetExecutionArtifactsResponse: @@ -153,7 +158,7 @@ def executions_artifacts(self, id: Any) -> GetExecutionArtifactsResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetExecutionArtifactsResponse, + response_model=self._response_model('GetExecutionArtifactsResponse', GetExecutionArtifactsResponse), ) def executions_container_log(self, id: Any) -> GetContainerExecutionLogResponse: @@ -163,7 +168,7 @@ def executions_container_log(self, id: Any) -> GetContainerExecutionLogResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetContainerExecutionLogResponse, + response_model=self._response_model('GetContainerExecutionLogResponse', GetContainerExecutionLogResponse), ) def executions_container_state(self, id: Any, include_execution_nodes_linked_to_same_container_execution: Any = None) -> GetContainerExecutionStateResponse: @@ -173,7 +178,7 @@ def executions_container_state(self, id: Any, include_execution_nodes_linked_to_ path_params={'id': id}, params={'include_execution_nodes_linked_to_same_container_execution': include_execution_nodes_linked_to_same_container_execution}, json_data=None, - response_model=GetContainerExecutionStateResponse, + response_model=self._response_model('GetContainerExecutionStateResponse', GetContainerExecutionStateResponse), ) def executions_details(self, id: Any) -> GetExecutionInfoResponse: @@ -183,7 +188,7 @@ def executions_details(self, id: Any) -> GetExecutionInfoResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetExecutionInfoResponse, + response_model=self._response_model('GetExecutionInfoResponse', GetExecutionInfoResponse), ) def executions_graph_execution_state(self, id: Any) -> GetGraphExecutionStateResponse: @@ -193,7 +198,7 @@ def executions_graph_execution_state(self, id: Any) -> GetGraphExecutionStateRes path_params={'id': id}, params=None, json_data=None, - response_model=GetGraphExecutionStateResponse, + response_model=self._response_model('GetGraphExecutionStateResponse', GetGraphExecutionStateResponse), ) def executions_state(self, id: Any) -> GetGraphExecutionStateResponse: @@ -203,7 +208,7 @@ def executions_state(self, id: Any) -> GetGraphExecutionStateResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetGraphExecutionStateResponse, + response_model=self._response_model('GetGraphExecutionStateResponse', GetGraphExecutionStateResponse), ) def pipeline_runs_list(self, page_token: Any = None, filter: Any = None, filter_query: Any = None, include_pipeline_names: Any = None, include_execution_stats: Any = None) -> ListPipelineJobsResponse: @@ -213,7 +218,7 @@ def pipeline_runs_list(self, page_token: Any = None, filter: Any = None, filter_ path_params=None, params={'page_token': page_token, 'filter': filter, 'filter_query': filter_query, 'include_pipeline_names': include_pipeline_names, 'include_execution_stats': include_execution_stats}, json_data=None, - response_model=ListPipelineJobsResponse, + response_model=self._response_model('ListPipelineJobsResponse', ListPipelineJobsResponse), ) def pipeline_runs_create(self, body: Any = None) -> PipelineRunResponse: @@ -223,7 +228,7 @@ def pipeline_runs_create(self, body: Any = None) -> PipelineRunResponse: path_params=None, params=None, json_data=body, - response_model=PipelineRunResponse, + response_model=self._response_model('PipelineRunResponse', PipelineRunResponse), ) def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> PipelineRunResponse: @@ -233,7 +238,7 @@ def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> Pip path_params={'id': id}, params={'include_execution_stats': include_execution_stats}, json_data=None, - response_model=PipelineRunResponse, + response_model=self._response_model('PipelineRunResponse', PipelineRunResponse), ) def pipeline_runs_annotations(self, id: Any) -> dict[str, Any]: @@ -283,7 +288,7 @@ def published_components_list(self, include_deprecated: Any = None, name_substri path_params=None, params={'include_deprecated': include_deprecated, 'name_substring': name_substring, 'published_by_substring': published_by_substring, 'digest': digest}, json_data=None, - response_model=ListPublishedComponentsResponse, + response_model=self._response_model('ListPublishedComponentsResponse', ListPublishedComponentsResponse), ) def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None) -> PublishedComponentResponse: @@ -293,7 +298,7 @@ def published_components_create(self, digest: Any = None, name: Any = None, tag: path_params=None, params=None, json_data={key: value for key, value in {'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}.items() if value is not None}, - response_model=PublishedComponentResponse, + response_model=self._response_model('PublishedComponentResponse', PublishedComponentResponse), ) def published_components_update(self, digest: Any, deprecated: Any = None, superseded_by: Any = None) -> PublishedComponentResponse: @@ -303,7 +308,7 @@ def published_components_update(self, digest: Any, deprecated: Any = None, super path_params={'digest': digest}, params={'deprecated': deprecated, 'superseded_by': superseded_by}, json_data=None, - response_model=PublishedComponentResponse, + response_model=self._response_model('PublishedComponentResponse', PublishedComponentResponse), ) def secrets_list(self) -> ListSecretsResponse: @@ -313,7 +318,7 @@ def secrets_list(self) -> ListSecretsResponse: path_params=None, params=None, json_data=None, - response_model=ListSecretsResponse, + response_model=self._response_model('ListSecretsResponse', ListSecretsResponse), ) def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: @@ -323,7 +328,7 @@ def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = path_params=None, params={'secret_name': secret_name, 'description': description, 'expires_at': expires_at}, json_data={'secret_value': secret_value}, - response_model=SecretInfoResponse, + response_model=self._response_model('SecretInfoResponse', SecretInfoResponse), ) def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: @@ -333,7 +338,7 @@ def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = path_params={'secret_name': secret_name}, params={'description': description, 'expires_at': expires_at}, json_data={'secret_value': secret_value}, - response_model=SecretInfoResponse, + response_model=self._response_model('SecretInfoResponse', SecretInfoResponse), ) def secrets_delete(self, secret_name: Any) -> None: @@ -353,7 +358,7 @@ def users_me(self) -> GetUserResponse | None: path_params=None, params=None, json_data=None, - response_model=GetUserResponse, + response_model=self._response_model('GetUserResponse', GetUserResponse), ) def users_me_settings(self, setting_names: Any = None) -> UserSettingsResponse: @@ -363,7 +368,7 @@ def users_me_settings(self, setting_names: Any = None) -> UserSettingsResponse: path_params=None, params={'setting_names': setting_names}, json_data=None, - response_model=UserSettingsResponse, + response_model=self._response_model('UserSettingsResponse', UserSettingsResponse), ) def users_patch_me_settings(self, body: Any = None) -> None: diff --git a/packages/tangle-cli/src/tangle_cli/generated_runtime.py b/packages/tangle-api/src/tangle_api/generated/runtime.py similarity index 94% rename from packages/tangle-cli/src/tangle_cli/generated_runtime.py rename to packages/tangle-api/src/tangle_api/generated/runtime.py index b052b35..aced4f7 100644 --- a/packages/tangle-cli/src/tangle_cli/generated_runtime.py +++ b/packages/tangle-api/src/tangle_api/generated/runtime.py @@ -1,4 +1,4 @@ -"""Runtime helpers shared by generated Tangle API model packages.""" +"""Runtime helpers for generated Tangle API model packages.""" from __future__ import annotations diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py index fa83c8d..a1cb5f7 100644 --- a/packages/tangle-cli/src/tangle_cli/__init__.py +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -1,9 +1,9 @@ """tangle-cli public API. -The package import is intentionally lightweight: native static API bindings live -in ``tangle_api.generated`` and may be supplied by the consumer environment. -Import ``tangle_cli.client.TangleApiClient`` explicitly when those generated -bindings are available. +The package import is intentionally lightweight: static API bindings live in +``tangle_api.generated`` (included by default in public installs, or supplied by +source/downstream environments). Import ``tangle_cli.client.TangleApiClient`` +explicitly when generated bindings should be loaded. """ from importlib.metadata import PackageNotFoundError diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index 3a496f7..4a80863 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -503,7 +503,8 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: raise SystemExit( f"No cached OpenAPI schema for {_normalize_base_url(base_url)}. " "Run `tangle api refresh` with the same --base-url/--auth-header/--header options, " - "or install tangle-cli[native] to use the official static schema." + "or use a tangle-cli environment with an official or custom tangle-api package " + "that provides tangle_api.schema." ) return cached @@ -571,9 +572,12 @@ def _api_tail_requests_help(api_tail: list[str]) -> bool: def _missing_official_schema_message() -> str: return ( - "Official static Tangle API commands require the native tangle-api " - "package because the bundled OpenAPI snapshot lives in tangle_api.schema. " - "Install tangle-cli[native], or run `tangle api refresh` and use " + "Official static Tangle API commands require a tangle-api package " + "because the OpenAPI snapshot lives in tangle_api.schema. Normal " + "tangle-cli installs include the official package; custom generated " + "API projects should run with a local src/tangle_api package that " + "shadows site-packages or install a compatible private tangle-api " + "distribution. Otherwise run `tangle api refresh` and use " "`--schema-source cache` for cached backend operations." ) diff --git a/packages/tangle-cli/src/tangle_cli/cli_helpers.py b/packages/tangle-cli/src/tangle_cli/cli_helpers.py index f2c704b..7868fb4 100644 --- a/packages/tangle-cli/src/tangle_cli/cli_helpers.py +++ b/packages/tangle-cli/src/tangle_cli/cli_helpers.py @@ -66,8 +66,8 @@ def api_arg_specs( class LazyTangleApiClient: """Instantiate the generated API client only when a command uses it. - Importing CLI modules must stay native-free so local-only commands can run - without the generated ``tangle_api`` package. This proxy delays importing and + Importing CLI modules must not eagerly load generated bindings, so local-only + commands can run without importing ``tangle_api``. This proxy delays importing and constructing ``TangleApiClient`` until an API method is actually accessed, while keeping CLI-friendly error wording in the CLI helper layer. """ @@ -85,9 +85,10 @@ def _get_client(self) -> Any: except ModuleNotFoundError as exc: if exc.name == "tangle_api": raise SystemExit( - "Native generated Tangle API bindings are required for " - f"{self.command_name}. Install tangle-cli[native] or provide " - "a local tangle_api.generated package." + "Generated Tangle API bindings are required for " + f"{self.command_name}. 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." ) from exc raise @@ -97,7 +98,7 @@ def _get_client(self) -> Any: return self._client def require_available(self) -> None: - """Materialize the client so CLI commands fail before native helper imports.""" + """Materialize the client so CLI commands fail before helper imports.""" self._get_client() diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index b8440b9..060c960 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -26,11 +26,13 @@ log_http_exchange, tangle_verbose_enabled, ) -from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse from tangle_api.generated.operations import GeneratedTangleApiOperations +from . import models as _cli_models from .logger import Logger, _null_logger, get_default_logger from .models import ( ComponentInfo, + ComponentSpec, + GetExecutionInfoResponse, GraphExecutionState, PipelineRun, RunDetails, @@ -78,6 +80,11 @@ def __init__( self.session = session or requests.Session() self.include_env_credentials = include_env_credentials + def _response_model(self, model_name: str, default: Any) -> Any: + """Use CLI-composed models for generated operation deserialization.""" + + return getattr(_cli_models, model_name, default) + def set_verbose(self, enabled: bool) -> None: """Enable or disable request logging.""" diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index 54966b4..5db339d 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -22,7 +22,7 @@ from .logger import Logger if TYPE_CHECKING: - from tangle_api.generated.models import ComponentSpec + from tangle_cli.models import ComponentSpec class ProcessingOutcome(str, Enum): @@ -171,12 +171,12 @@ def _component_spec_model(self) -> type[Any]: if self.component_spec_model is not None: return self.component_spec_model try: - from tangle_api.generated.models import ComponentSpec + from tangle_cli.models import ComponentSpec except ModuleNotFoundError as exc: if exc.name == "tangle_api": raise RuntimeError( "Native generated Tangle API bindings are required for component publishing. " - "Install tangle-cli[native] or provide a local tangle_api.generated package." + "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." ) from exc raise return ComponentSpec diff --git a/packages/tangle-cli/src/tangle_cli/handler.py b/packages/tangle-cli/src/tangle_cli/handler.py index df50c9d..03b7a59 100644 --- a/packages/tangle-cli/src/tangle_cli/handler.py +++ b/packages/tangle-cli/src/tangle_cli/handler.py @@ -64,7 +64,7 @@ def _create_client(self) -> Any | None: if exc.name == "tangle_api": self.log.error( "❌ Native generated Tangle API bindings are required for Tangle API operations. " - "Install tangle-cli[native] or provide a local tangle_api.generated package." + "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." ) return None raise diff --git a/packages/tangle-cli/src/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py index 8ae96da..e7a5247 100644 --- a/packages/tangle-cli/src/tangle_cli/models.py +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -11,9 +11,56 @@ from dataclasses import asdict, dataclass, field from typing import Any -from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse +from tangle_api.generated import models as _generated_models from .artifacts import ArtifactComponentQuery, ArtifactInfo +from . import generated_model_extensions as _cli_model_extensions + + +def extend_model(public_name: str, base: type[Any], *mixins: type[Any]) -> type[Any]: + """Compose a generated model with downstream mixins in this namespace.""" + + if not mixins: + return base + model = type(public_name, (*mixins, base), {"__module__": __name__}) + model_rebuild = getattr(model, "model_rebuild", None) + if callable(model_rebuild): + model_rebuild() + return model + + +def compose_models(generated_models_module: Any, *extension_modules: Any) -> dict[str, type[Any]]: + """Return generated models composed with MODEL_EXTENSIONS mappings. + + Extension modules declare ``MODEL_EXTENSIONS = {"ModelName": "MixinName"}``. + Composition happens in this downstream namespace and never mutates + ``tangle_api.generated.models``. + """ + + composed: dict[str, type[Any]] = {} + public_names = getattr(generated_models_module, "__all__", None) + if public_names is None: + public_names = [ + name + for name, value in vars(generated_models_module).items() + if not name.startswith("_") and isinstance(value, type) + ] + for public_name in public_names: + base = getattr(generated_models_module, public_name) + mixins: list[type[Any]] = [] + for module in extension_modules: + mapping = getattr(module, "MODEL_EXTENSIONS", {}) + if not isinstance(mapping, dict): + continue + mixin_name = mapping.get(public_name) + if mixin_name: + mixins.append(getattr(module, mixin_name)) + composed[public_name] = extend_model(public_name, base, *mixins) + return composed + + +_COMPOSED_MODELS = compose_models(_generated_models, _cli_model_extensions) +globals().update(_COMPOSED_MODELS) # ---- Execution / Run dataclasses ------------------------------------------- @@ -296,10 +343,9 @@ def from_dict(cls, data: dict[str, Any]) -> SecretInfo: # ---- Components ------------------------------------------------------------ -# ``ComponentSpec`` is generated from OpenAPI and extended in -# ``tangle_cli.generated_model_extensions.ComponentSpecExtensions``. Re-export -# it from this module for compatibility with callers that import domain models -# from ``tangle_cli.models``. +# ``ComponentSpec`` is generated from OpenAPI and extended at import time in +# this module. Re-export it for compatibility with callers that import domain +# models from ``tangle_cli.models``. @dataclass diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index 08c0026..e95842c 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -22,9 +22,7 @@ import tempfile import urllib.parse import urllib.request -from collections import Counter from collections.abc import Sequence -from dataclasses import dataclass from pathlib import Path from typing import Any @@ -40,7 +38,6 @@ _GENERATED_DIR = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" / "generated" DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle" DEFAULT_OPERATIONS_CLASS_NAME = "GeneratedTangleApiOperations" -DEFAULT_MODEL_EXTENSION_MODULE = "tangle_cli.generated_model_extensions" DEFAULT_MODEL_ALIASES: dict[str, tuple[str, ...]] = { "ComponentSpec": ( "ComponentSpec-Output", @@ -373,171 +370,63 @@ def _apply_request_body_schema_overrides( return output -@dataclass(frozen=True) -class _ModelExtensionRef: - """Import reference for one generated model extension class.""" +def generate_runtime() -> str: + """Generate the small runtime module used by generated Pydantic models.""" - module_name: str - class_name: str - alias: str + return '''"""Runtime helpers for generated Tangle API model packages.""" +from __future__ import annotations -def _model_extension_modules( - model_extension_module: str | Sequence[str] | None, -) -> list[str]: - """Return ordered model extension modules, applying defaults first. +from typing import Any - ``None`` means the built-in default module. A string or sequence appends - downstream modules after the built-in default. The empty-string sentinel - disables the default module and is otherwise ignored. - """ +from pydantic import BaseModel - if model_extension_module is None: - modules: list[str] = [] - elif isinstance(model_extension_module, str): - modules = [model_extension_module] - else: - modules = list(model_extension_module) +try: + from pydantic import ConfigDict +except ImportError: # pragma: no cover - pydantic v1 fallback + ConfigDict = None # type: ignore[assignment] - include_default = True - if "" in modules: - include_default = False - modules = [module for module in modules if module != ""] - ordered = ([DEFAULT_MODEL_EXTENSION_MODULE] if include_default else []) + modules - deduped: list[str] = [] - seen: set[str] = set() - for module in ordered: - if not module or module in seen: - continue - seen.add(module) - deduped.append(module) - return deduped +class TangleGeneratedModel(BaseModel): + """Base for generated response models with dict-like conveniences.""" + if ConfigDict is not None: + model_config = ConfigDict(extra="allow", populate_by_name=True) + else: # pragma: no cover - pydantic v1 fallback + class Config: + extra = "allow" + allow_population_by_field_name = True -def _validate_module_name(module_name: str) -> str: - parts = module_name.split(".") - if not parts or any(not re.fullmatch(r"[A-Za-z_]\w*", part) or keyword.iskeyword(part) for part in parts): - raise ValueError(f"Invalid model extension module name: {module_name!r}") - return module_name + def get(self, key: str, default: Any = None) -> Any: + return self.to_dict().get(key, default) + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] -def _model_extension_mapping(module_name: str) -> dict[str, str]: - """Load and validate a MODEL_EXTENSIONS mapping from an extension module.""" + def to_dict(self) -> dict[str, Any]: + if hasattr(self, "model_dump"): + return self.model_dump(by_alias=True) + return self.dict(by_alias=True) - module_name = _validate_module_name(module_name) - try: - module = importlib.import_module(module_name) - except Exception as exc: # pragma: no cover - importlib preserves details - raise ValueError(f"Could not import model extension module {module_name!r}: {exc}") from exc + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Any: + if hasattr(cls, "model_validate"): + return cls.model_validate(data) + return cls.parse_obj(data) - mapping = getattr(module, "MODEL_EXTENSIONS", None) - if not isinstance(mapping, dict): - raise ValueError( - f"Model extension module {module_name!r} must define a MODEL_EXTENSIONS dict" - ) - extensions: dict[str, str] = {} - for model_name, extension_name in mapping.items(): - if not isinstance(model_name, str) or not isinstance(extension_name, str): - raise ValueError("MODEL_EXTENSIONS keys and values must be strings") - _validate_class_name(model_name) - _validate_class_name(extension_name) - if not hasattr(module, extension_name): - raise ValueError( - f"Model extension module {module_name!r} does not define {extension_name!r}" - ) - extensions[model_name] = extension_name - return extensions - - -def _model_extension_refs( - model_extension_module: str | Sequence[str] | None, -) -> dict[str, list[_ModelExtensionRef]]: - """Resolve model extension refs by generated class in configured order.""" - - refs_by_model: dict[str, list[_ModelExtensionRef]] = {} - raw_refs: list[_ModelExtensionRef] = [] - for module_name in _model_extension_modules(model_extension_module): - for model_name, extension_name in _model_extension_mapping(module_name).items(): - ref = _ModelExtensionRef( - module_name=module_name, - class_name=extension_name, - alias=extension_name, - ) - refs_by_model.setdefault(model_name, []).append(ref) - raw_refs.append(ref) - - unique_ref_keys: list[tuple[str, str]] = [] - seen_ref_keys: set[tuple[str, str]] = set() - for ref in raw_refs: - key = (ref.module_name, ref.class_name) - if key in seen_ref_keys: - continue - seen_ref_keys.add(key) - unique_ref_keys.append(key) - - class_name_counts = Counter(class_name for _, class_name in unique_ref_keys) - alias_counts: Counter[str] = Counter() - aliases_by_ref: dict[tuple[str, str], str] = {} - for module_name, class_name in unique_ref_keys: - if class_name_counts[class_name] == 1: - alias = class_name - else: - alias_base = f"_{_safe_identifier(module_name)}_{class_name}" - alias_counts[alias_base] += 1 - alias = alias_base if alias_counts[alias_base] == 1 else f"{alias_base}_{alias_counts[alias_base]}" - aliases_by_ref[(module_name, class_name)] = alias - - aliased: dict[str, list[_ModelExtensionRef]] = {} - for model_name, refs in refs_by_model.items(): - aliased[model_name] = [] - for ref in refs: - aliased[model_name].append( - _ModelExtensionRef( - module_name=ref.module_name, - class_name=ref.class_name, - alias=aliases_by_ref[(ref.module_name, ref.class_name)], - ) - ) - return aliased - - -def _model_extension_import_lines(refs_by_model: dict[str, list[_ModelExtensionRef]]) -> list[str]: - """Render deterministic import lines for configured model extensions.""" - - refs_by_module: dict[str, list[_ModelExtensionRef]] = {} - for refs in refs_by_model.values(): - for ref in refs: - refs_by_module.setdefault(ref.module_name, []).append(ref) - - lines: list[str] = [] - for module_name, refs in sorted(refs_by_module.items()): - imports: list[str] = [] - seen: set[tuple[str, str]] = set() - for ref in sorted(refs, key=lambda item: (item.class_name, item.alias)): - key = (ref.class_name, ref.alias) - if key in seen: - continue - seen.add(key) - if ref.alias == ref.class_name: - imports.append(ref.class_name) - else: - imports.append(f"{ref.class_name} as {ref.alias}") - lines.append(f"from {module_name} import {', '.join(imports)}") - return lines +__all__ = ["TangleGeneratedModel"] +''' def generate_models( schema: dict[str, Any], - model_extension_module: str | Sequence[str] | None = None, model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None, ) -> str: - """Generate Pydantic model classes and apply configured model extensions.""" + """Generate plain/base Pydantic model classes.""" raw_schemas = schema.get("components", {}).get("schemas", {}) or {} schemas, _ = _apply_model_aliases(raw_schemas, model_aliases) - extension_refs = _model_extension_refs(model_extension_module) lines: list[str] = [ '"""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"""', "", @@ -547,26 +436,10 @@ def generate_models( "", "from pydantic import Field", "", - "from tangle_cli.generated_runtime import TangleGeneratedModel", + "from tangle_api.generated.runtime import TangleGeneratedModel", "", ] - generated_class_names = { - _class_name(schema_name) - for schema_name, schema_def in schemas.items() - if isinstance(schema_def, dict) - and (schema_def.get("type") in {"object", None} or "properties" in schema_def) - } - used_extensions = { - class_name: extension_refs[class_name] - for class_name in sorted(generated_class_names) - if class_name in extension_refs - } - imports = _model_extension_import_lines(used_extensions) - if imports: - lines.extend(imports) - lines.append("") - exports: list[str] = [] for schema_name, schema_def in sorted(schemas.items(), key=lambda item: _class_name(item[0])): class_name = _class_name(schema_name) @@ -575,9 +448,7 @@ def generate_models( lines.extend([f"{class_name} = Any", ""]) continue properties = schema_def.get("properties") or {} - extension_refs_for_class = used_extensions.get(class_name, []) - generated_base_name = f"_{class_name}Generated" - lines.extend([f"class {generated_base_name}(TangleGeneratedModel):"]) + lines.append(f"class {class_name}(TangleGeneratedModel):") if not properties: lines.append(" pass") else: @@ -588,13 +459,6 @@ def generate_models( else: lines.append(f" {field_name}: Any = None") lines.append("") - extension_bases = [ref.alias for ref in reversed(extension_refs_for_class)] - bases = extension_bases + [generated_base_name] - lines.extend([ - f"class {class_name}({', '.join(bases)}):", - " pass", - "", - ]) lines.append(f"__all__ = {exports!r}") lines.append("") @@ -727,6 +591,11 @@ def generate_operations( " response_model: Any = None,", " ) -> Any: ...", "", + " def _response_model(self, model_name: str, default: Any) -> Any:", + " \"\"\"Return the model class used to deserialize a generated response.\"\"\"", + "", + " return default", + "", ]) used_methods: set[str] = set() @@ -742,7 +611,7 @@ def generate_operations( raw_body_override=bool(operation.operation.get("x-tangle-cli-request-body-schema-override")), ) response_model = _response_model_name(operation.operation, model_ref_aliases) - response_arg = response_model if response_model else "None" + response_arg = f"self._response_model({response_model!r}, {response_model})" if response_model else "None" response_annotation = _response_return_annotation(operation.operation, model_ref_aliases) if signature: def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:" @@ -871,7 +740,6 @@ def generate( generated_dir: str | Path = _GENERATED_DIR, *, operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, - model_extension_module: str | Sequence[str] | None = None, model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None, request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None = None, ) -> tuple[dict[str, Any], list[Path]]: @@ -880,6 +748,7 @@ def generate( output_dir.mkdir(parents=True, exist_ok=True) generated_files = [ output_dir / "__init__.py", + output_dir / "runtime.py", output_dir / "models.py", output_dir / "operations.py", ] @@ -887,15 +756,15 @@ def generate( '"""Generated OpenAPI support modules."""\n', encoding="utf-8", ) - generated_files[1].write_text( + generated_files[1].write_text(generate_runtime(), encoding="utf-8") + generated_files[2].write_text( generate_models( schema, - model_extension_module=model_extension_module, model_aliases=model_aliases, ), encoding="utf-8", ) - generated_files[2].write_text( + generated_files[3].write_text( generate_operations( schema, operations_class_name=operations_class_name, @@ -961,19 +830,6 @@ def main(argv: list[str] | None = None) -> None: f"(default: {DEFAULT_OPERATIONS_CLASS_NAME})." ), ) - parser.add_argument( - "--model-extension-module", - action="append", - default=None, - help=( - "Importable module containing a MODEL_EXTENSIONS mapping from " - "generated model class names to extension class names. Repeat to " - "compose modules in order; later modules override earlier ones. " - "The built-in default module is applied first unless an empty string " - "is passed to disable it. " - f"(default first: {DEFAULT_MODEL_EXTENSION_MODULE})." - ), - ) parser.add_argument( "--model-alias", action="append", @@ -1031,7 +887,6 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) try: _validate_class_name(args.operations_class_name) - _model_extension_refs(args.model_extension_module) _model_alias_mapping(args.model_alias) request_body_schema_overrides = _request_body_schema_mapping(args.request_body_schema) 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: openapi_path, args.out, operations_class_name=args.operations_class_name, - model_extension_module=args.model_extension_module, model_aliases=args.model_alias, request_body_schemas=request_body_schema_overrides, ) diff --git a/packages/tangle-cli/src/tangle_cli/openapi/parser.py b/packages/tangle-cli/src/tangle_cli/openapi/parser.py index b5ebcd8..6a024ab 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/parser.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/parser.py @@ -49,9 +49,10 @@ def _load_default_openapi_schema() -> dict[str, Any]: last_error = exc if schema_text is None: raise FileNotFoundError( - "Default OpenAPI snapshot not found. Install tangle-api, run from a " - "source checkout with packages/tangle-api/src/tangle_api/schema/openapi.json, " - "or pass --openapi PATH explicitly." + "Default OpenAPI snapshot not found. Install the default or a compatible " + "custom tangle-api package, run from a source checkout with " + "packages/tangle-api/src/tangle_api/schema/openapi.json, or pass " + "--openapi PATH explicitly." ) from last_error schema = json.loads(schema_text) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py index c87a288..6a1bc3a 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -28,9 +28,9 @@ class PageChunk: """Metadata for a single page of search results. - Defined locally to keep this module importable without the native - ``tangle-api`` extra; ``tangle_cli.models`` re-exports an equivalent - dataclass when native models are available. + Defined locally to keep this module importable without loading generated + ``tangle-api`` bindings; ``tangle_cli.models`` re-exports an equivalent + dataclass when generated models are imported. """ rows: list[dict[str, Any]] diff --git a/packages/tangle-cli/src/tangle_cli/quickstart.py b/packages/tangle-cli/src/tangle_cli/quickstart.py index 2f38060..c09f15a 100644 --- a/packages/tangle-cli/src/tangle_cli/quickstart.py +++ b/packages/tangle-cli/src/tangle_cli/quickstart.py @@ -1,4 +1,4 @@ -"""Native-free quickstart text for the root ``tangle`` CLI.""" +"""Quickstart text for the root ``tangle`` CLI.""" from __future__ import annotations @@ -7,7 +7,7 @@ from cyclopts import App -app = App(name="quickstart", help="Print a concise native-free guide to the Tangle CLI.") +app = App(name="quickstart", help="Print a concise guide to the Tangle CLI.") QUICKSTART_TEXT = dedent( @@ -76,16 +76,15 @@ dynamic schema discovery, codegen, logging, hydrator/resolver logic, and extension hooks. - tangle_api is the generated/native package: checked-in Pydantic models, - endpoint operation methods, and the official OpenAPI snapshot. Local-only - SDK commands and this quickstart do not need it. Static API-backed commands - need tangle-cli[native] or an equivalent local tangle_api.generated package. + tangle_api is the generated package: checked-in Pydantic models, + endpoint operation methods, and the official OpenAPI snapshot. Public + tangle-cli installs include the matching tangle-api package by default. + Codegen/custom API projects can still generate a local src/tangle_api + package that shadows site-packages, or provide a compatible private + distribution named tangle-api for their environment. - Generated model extensions use private generated bases plus stable public - subclasses, e.g. ComponentSpec(ComponentSpecExtensions, - _ComponentSpecGenerated). Extension bases are left of the generated base in - the MRO, and downstream --model-extension-module values can add/override - behavior while preserving generated fields and stable names. + Generated model extensions are composed at runtime in downstream namespaces + such as tangle_cli.models, leaving tangle_api generated models plain/leaf. Discover more ------------- @@ -105,6 +104,6 @@ @app.default def quickstart() -> None: - """Print a concise native-free guide to the Tangle CLI.""" + """Print a concise guide to the Tangle CLI.""" print(QUICKSTART_TEXT) diff --git a/pyproject.toml b/pyproject.toml index 5d6ffa1..8e4bf86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.0.1a2" +version = "0.0.1a3" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", "requests>=2.32.0", + "tangle-api==0.0.1a3", "tomli>=2.0; python_version < '3.11'", ] @@ -28,7 +29,8 @@ Repository = "https://github.com/TangleML/tangle-cli" Issues = "https://github.com/TangleML/tangle-cli/issues" [project.optional-dependencies] -native = ["tangle-api==0.0.1a2"] +# Compatibility alias: tangle-api is now part of the default public install. +native = [] [project.scripts] tangle = "tangle_cli.cli:main" diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index c38a98d..c93a575 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -926,8 +926,10 @@ def fail_load_schema(): api_cli.build_app() message = str(exc_info.value) - assert "Official static Tangle API commands require the native tangle-api package" in message - assert "Install tangle-cli[native]" in message + assert "Official static Tangle API commands require a tangle-api package" in message + assert "custom generated API projects" in message + assert "shadows site-packages" in message + assert "compatible private tangle-api distribution" in message assert "--schema-source cache" in message diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py index c7779d8..b7b3ec1 100644 --- a/tests/test_artifacts_cli.py +++ b/tests/test_artifacts_cli.py @@ -161,8 +161,9 @@ def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: else: # pragma: no cover - defensive assertion raise AssertionError("expected missing native API to fail") - assert "Native generated Tangle API bindings are required for artifact commands" in message - assert "Install tangle-cli[native]" in message + assert "Generated Tangle API bindings are required for artifact commands" in message + assert "Install the default tangle-cli package with tangle-api" in message + assert "local src/tangle_api shadows site-packages" in message def test_sdk_artifacts_get_cli_requires_query(tmp_path) -> None: diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 9a3b3a0..d7308fe 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -146,7 +146,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[1][0] == "generate" assert calls[1][1]["openapi_path"] == default_snapshot assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[1][1]["model_extension_module"] is None assert calls[1][1]["model_aliases"] is None output = capsys.readouterr().out assert f"Loaded OpenAPI from backend: {backend}" in output @@ -201,7 +200,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[0][1]["model_extension_module"] is None assert calls[0][1]["model_aliases"] is None output = capsys.readouterr().out assert f"Loaded OpenAPI from snapshot: {tmp_path / 'openapi.json'}" in output @@ -266,36 +264,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiExtensions" - assert calls[0][1]["model_extension_module"] is None - assert calls[0][1]["model_aliases"] is None - - -def test_codegen_main_accepts_empty_model_extension_module(monkeypatch, tmp_path) -> None: - calls: list[tuple[str, object]] = [] - - def fake_generate(openapi_path, generated_dir, **kwargs): - calls.append(( - "generate", - { - "openapi_path": openapi_path, - "generated_dir": generated_dir, - **kwargs, - }, - )) - return _schema(), _generated_files(tmp_path) - - monkeypatch.setattr(codegen, "generate", fake_generate) - - codegen.main([ - "--openapi", - str(tmp_path / "openapi.json"), - "--from-snapshot", - "--model-extension-module", - "", - ]) - - assert calls[0][0] == "generate" - assert calls[0][1]["model_extension_module"] == [""] assert calls[0][1]["model_aliases"] is None @@ -344,7 +312,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[1][0] == "generate" assert calls[1][1]["openapi_path"] == default_snapshot assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[1][1]["model_extension_module"] is None assert calls[1][1]["model_aliases"] is None output = capsys.readouterr().out assert "Loaded OpenAPI from URL: https://example.com/openapi.json" in output @@ -463,16 +430,16 @@ def test_generate_models_adds_default_component_spec_alias() -> None: }, } - models = codegen.generate_models(schema, model_extension_module="") + models = codegen.generate_models(schema) operations = codegen.generate_operations(schema) - assert "class _ComponentSpecGenerated(TangleGeneratedModel):" in models - assert "class ComponentSpec(_ComponentSpecGenerated):" in models - assert "class _ComponentSpecOutputGenerated(TangleGeneratedModel):" in models + assert "class ComponentSpec(TangleGeneratedModel):" in models + assert "class ComponentSpecOutput(TangleGeneratedModel):" in models + assert "_ComponentSpecGenerated" not in models assert "'ComponentSpec'" in models assert "from .models import ComponentSpec" in operations assert "def components_get(self, digest: Any) -> ComponentSpec:" in operations - assert "response_model=ComponentSpec" in operations + assert "response_model=self._response_model('ComponentSpec', ComponentSpec)" in operations def test_component_spec_alias_operation_deserializes_raw_spec(monkeypatch, tmp_path) -> None: @@ -523,8 +490,9 @@ def _request_json(self, *args, response_model=None, **kwargs): assert spec.__class__.__name__ == "ComponentSpec" assert spec.name == "Widget" - assert spec.version == "1" - assert spec.data["name"] == "Widget" + assert spec.metadata == {"annotations": {"version": "1"}} + assert not hasattr(spec, "version") + assert not hasattr(spec, "data") def test_generate_models_can_disable_default_model_aliases() -> None: @@ -541,10 +509,10 @@ def test_generate_models_can_disable_default_model_aliases() -> None: }, } - models = codegen.generate_models(schema, model_extension_module="", model_aliases="") + models = codegen.generate_models(schema, model_aliases="") - assert "class _ComponentSpecGenerated" not in models - assert "class _ComponentSpecOutputGenerated(TangleGeneratedModel):" in models + assert "class ComponentSpec(" not in models + assert "class ComponentSpecOutput(TangleGeneratedModel):" in models def test_generate_models_supports_custom_model_aliases() -> None: @@ -575,17 +543,17 @@ def test_generate_models_supports_custom_model_aliases() -> None: }, } - models = codegen.generate_models(schema, model_extension_module="", model_aliases=["Widget=WidgetOutput"]) + models = codegen.generate_models(schema, model_aliases=["Widget=WidgetOutput"]) operations = codegen.generate_operations(schema, model_aliases=["Widget=WidgetOutput"]) - assert "class _WidgetGenerated(TangleGeneratedModel):" in models - assert "class Widget(_WidgetGenerated):" in models + assert "class Widget(TangleGeneratedModel):" in models + assert "_WidgetGenerated" not in models assert "from .models import Widget" in operations assert "-> Widget:" in operations - assert "response_model=Widget" in operations + assert "response_model=self._response_model('Widget', Widget)" in operations -def test_generate_models_uses_builtin_model_extension_module_by_default() -> None: +def test_generate_models_are_plain_by_default() -> None: models = codegen.generate_models({ "openapi": "3.1.0", "paths": {}, @@ -601,218 +569,10 @@ def test_generate_models_uses_builtin_model_extension_module_by_default() -> Non }, }) - assert "from tangle_cli.generated_runtime import TangleGeneratedModel" in models - assert ( - "from tangle_cli.generated_model_extensions import " - "GetGraphExecutionStateResponseExtensions" - ) in models - assert "class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel):" in models - assert ( - "class GetGraphExecutionStateResponse(" - "GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated):" - ) in models - - -def test_generate_models_can_disable_builtin_model_extension_module() -> None: - models = codegen.generate_models({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "GetGraphExecutionStateResponse": { - "type": "object", - "properties": { - "child_execution_status_stats": {"type": "object"}, - }, - } - } - }, - }, model_extension_module="") - + assert "from tangle_api.generated.runtime import TangleGeneratedModel" in models assert "generated_model_extensions" not in models - assert "class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel):" in models - assert "class GetGraphExecutionStateResponse(_GetGraphExecutionStateResponseGenerated):" in models - - -def test_generate_supports_model_extension_module(monkeypatch, tmp_path) -> None: - extension_dir = tmp_path / "extensions" - extension_dir.mkdir() - (extension_dir / "demo_extensions.py").write_text( - "class FooResponseExtensions:\n" - " @property\n" - " def id(self):\n" - " return 'extended-id'\n" - " @property\n" - " def demo(self):\n" - " return 'extended'\n" - "\n" - "MODEL_EXTENSIONS = {\n" - " 'FooResponse': 'FooResponseExtensions',\n" - "}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(extension_dir)) - openapi = tmp_path / "openapi.json" - out = tmp_path / "custom_generated_api" - openapi.write_text( - json.dumps({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "FooResponse": { - "type": "object", - "properties": {"id": {"type": "string"}}, - }, - "OtherResponse": { - "type": "object", - "properties": {"id": {"type": "string"}}, - }, - } - }, - }), - encoding="utf-8", - ) - - codegen.generate( - openapi, - out, - model_extension_module="demo_extensions", - ) - - models = (out / "models.py").read_text(encoding="utf-8") - assert "from demo_extensions import FooResponseExtensions" in models - assert "class _FooResponseGenerated(TangleGeneratedModel):" in models - assert "class FooResponse(FooResponseExtensions, _FooResponseGenerated):" in models - assert "class _OtherResponseGenerated(TangleGeneratedModel):" in models - assert "class OtherResponse(_OtherResponseGenerated):" in models - - monkeypatch.syspath_prepend(str(tmp_path)) - generated_models = importlib.import_module("custom_generated_api.models") - response = generated_models.FooResponse(id="generated-id") - assert response.id == "extended-id" - assert response.to_dict()["id"] == "generated-id" - - - -def test_generate_composes_default_and_downstream_model_extensions(monkeypatch, tmp_path) -> None: - extension_dir = tmp_path / "extensions" - extension_dir.mkdir() - (extension_dir / "downstream_extensions.py").write_text( - "class GetGraphExecutionStateResponseExtensions:\n" - " @property\n" - " def status_totals(self):\n" - " return {'DOWNSTREAM': 1}\n" - "\n" - "MODEL_EXTENSIONS = {\n" - " 'GetGraphExecutionStateResponse': 'GetGraphExecutionStateResponseExtensions',\n" - "}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(extension_dir)) - openapi = tmp_path / "openapi.json" - out = tmp_path / "generated_graph_api" - openapi.write_text( - json.dumps({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "GetGraphExecutionStateResponse": { - "type": "object", - "properties": { - "child_execution_status_stats": {"type": "object"}, - }, - }, - } - }, - }), - encoding="utf-8", - ) - - codegen.generate( - openapi, - out, - model_extension_module="downstream_extensions", - ) - - models = (out / "models.py").read_text(encoding="utf-8") - assert ( - "from downstream_extensions import " - "GetGraphExecutionStateResponseExtensions as " - "_downstream_extensions_GetGraphExecutionStateResponseExtensions" - ) in models - assert ( - "from tangle_cli.generated_model_extensions import " - "GetGraphExecutionStateResponseExtensions as " - "_tangle_cli_generated_model_extensions_GetGraphExecutionStateResponseExtensions" - ) in models - assert ( - "class GetGraphExecutionStateResponse(" - "_downstream_extensions_GetGraphExecutionStateResponseExtensions, " - "_tangle_cli_generated_model_extensions_GetGraphExecutionStateResponseExtensions, " - "_GetGraphExecutionStateResponseGenerated):" - ) in models - - monkeypatch.syspath_prepend(str(tmp_path)) - generated_models = importlib.import_module("generated_graph_api.models") - response = generated_models.GetGraphExecutionStateResponse( - child_execution_status_stats={"exec-1": {"FAILED": 1}} - ) - assert response.status_totals == {"DOWNSTREAM": 1} - assert response.failed_execution_ids == ["exec-1"] - - -def test_generate_deduplicates_colliding_extension_aliases(monkeypatch, tmp_path) -> None: - package_dir = tmp_path / "a" - package_dir.mkdir() - (package_dir / "__init__.py").write_text("", encoding="utf-8") - (package_dir / "b.py").write_text( - "class Ext:\n" - " @property\n" - " def source(self):\n" - " return 'a.b'\n" - "\n" - "MODEL_EXTENSIONS = {'Foo': 'Ext'}\n", - encoding="utf-8", - ) - (tmp_path / "a_b.py").write_text( - "class Ext:\n" - " @property\n" - " def source(self):\n" - " return 'a_b'\n" - "\n" - "MODEL_EXTENSIONS = {'Bar': 'Ext'}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(tmp_path)) - openapi = tmp_path / "openapi.json" - out = tmp_path / "alias_collision_api" - openapi.write_text( - json.dumps({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "Foo": {"type": "object", "properties": {"id": {"type": "string"}}}, - "Bar": {"type": "object", "properties": {"id": {"type": "string"}}}, - } - }, - }), - encoding="utf-8", - ) - - codegen.generate(openapi, out, model_extension_module=["a.b", "a_b"]) - - models = (out / "models.py").read_text(encoding="utf-8") - assert "from a.b import Ext as _a_b_Ext" in models - assert "from a_b import Ext as _a_b_Ext_2" in models - assert "class Foo(_a_b_Ext, _FooGenerated):" in models - assert "class Bar(_a_b_Ext_2, _BarGenerated):" in models - - generated_models = importlib.import_module("alias_collision_api.models") - assert generated_models.Foo().source == "a.b" - assert generated_models.Bar().source == "a_b" + assert "class GetGraphExecutionStateResponse(TangleGeneratedModel):" in models + assert "_GetGraphExecutionStateResponseGenerated" not in models def test_generate_operations_request_body_schema_override_preserves_raw_body(monkeypatch, tmp_path) -> None: @@ -1044,34 +804,6 @@ def test_codegen_main_rejects_invalid_request_body_schema(tmp_path, capsys) -> N assert "not valid JSON" in capsys.readouterr().err -def test_codegen_main_rejects_invalid_model_extension_module(tmp_path, capsys) -> None: - with pytest.raises(SystemExit) as exc_info: - codegen.main([ - "--openapi", - str(tmp_path / "openapi.json"), - "--model-extension-module", - "not-valid!", - ]) - - assert exc_info.value.code == 2 - assert "Invalid model extension module name" in capsys.readouterr().err - - -def test_generate_rejects_invalid_model_extension_mapping(monkeypatch, tmp_path) -> None: - extension_dir = tmp_path / "extensions" - extension_dir.mkdir() - (extension_dir / "bad_extensions.py").write_text( - "MODEL_EXTENSIONS = {'FooResponse': 'MissingExtensions'}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(extension_dir)) - openapi = tmp_path / "openapi.json" - openapi.write_text(json.dumps({"openapi": "3.1.0", "paths": {}}), encoding="utf-8") - - with pytest.raises(ValueError, match="does not define"): - codegen.generate(openapi, tmp_path / "out", model_extension_module="bad_extensions") - - def test_generate_supports_custom_operations_class_name(tmp_path) -> None: openapi = tmp_path / "openapi.json" out = tmp_path / "custom_generated_api" @@ -1214,5 +946,6 @@ def test_generate_operations_uses_concrete_return_annotations() -> None: assert "def nullable_list(self) -> FooResponse | None:" in operations assert "def status_list(self) -> str:" in operations assert "def things_get(self, id: Any) -> FooResponse:" in operations + assert "response_model=self._response_model('FooResponse', FooResponse)" in operations assert "def things_delete(self, id: Any) -> None:" in operations assert "def unknown_list(self) -> Any:" in operations diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py index 8a35160..a6ac801 100644 --- a/tests/test_component_publisher.py +++ b/tests/test_component_publisher.py @@ -6,7 +6,7 @@ import yaml -from tangle_api.generated.models import ComponentSpec +from tangle_cli.models import ComponentSpec from tangle_cli.component_publisher import ( ComponentPublishContext, diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 1dc4708..5764695 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -385,8 +385,9 @@ def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: with pytest.raises(SystemExit) as exc_info: app(command) message = str(exc_info.value) - assert "Native generated Tangle API bindings are required for published-component commands" in message - assert "Install tangle-cli[native]" in message + assert "Generated Tangle API bindings are required for published-component commands" in message + assert "Install the default tangle-cli package with tangle-api" in message + assert "local src/tangle_api shadows site-packages" in message def test_published_components_publish_log_type_none_suppresses_progress(tmp_path: Path, capsys): diff --git a/tests/test_models.py b/tests/test_models.py index 9dc189e..25f172f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,17 +2,22 @@ from __future__ import annotations +from types import SimpleNamespace + +from tangle_api.generated import models as generated_models from tangle_api.generated.models import ( ArtifactData, ComponentSpec as GeneratedComponentSpec, GetArtifactInfoResponse, - GetExecutionInfoResponse, + GetExecutionInfoResponse as GeneratedGetExecutionInfoResponse, ) +import tangle_cli.models as model_module from tangle_cli.models import ( ArtifactInfo, ComponentInfo, ComponentSpec, ContainerState, + GetExecutionInfoResponse, GraphExecutionState, PipelineRun, SecretInfo, @@ -61,9 +66,28 @@ def test_failed_execution_ids(self): class TestComponentSpec: def test_component_spec_is_generated_model_with_extensions(self): - assert ComponentSpec is GeneratedComponentSpec + assert ComponentSpec is not GeneratedComponentSpec + assert issubclass(ComponentSpec, GeneratedComponentSpec) assert ComponentSpec.__mro__[1].__name__ == "ComponentSpecExtensions" + def test_compose_models_uses_model_extensions_mapping_without_mutating_generated_models(self): + class DemoComponentSpecMixin: + @property + def demo_marker(self): + return "runtime-extension" + + extension_module = SimpleNamespace( + MODEL_EXTENSIONS={"ComponentSpec": "DemoComponentSpecMixin"}, + DemoComponentSpecMixin=DemoComponentSpecMixin, + ) + + composed = model_module.compose_models(generated_models, extension_module) + + assert composed["ComponentSpec"] is not GeneratedComponentSpec + assert issubclass(composed["ComponentSpec"], GeneratedComponentSpec) + assert composed["ComponentSpec"](name="demo").demo_marker == "runtime-extension" + assert not hasattr(GeneratedComponentSpec(name="base"), "demo_marker") + def test_from_yaml_basic(self): yaml_text = """\ name: my-component @@ -294,6 +318,8 @@ def test_add_official_prefix_idempotent(self): class TestGetExecutionInfoResponse: def test_execution_details_generated_model_has_extensions(self): + assert GetExecutionInfoResponse is not GeneratedGetExecutionInfoResponse + assert issubclass(GetExecutionInfoResponse, GeneratedGetExecutionInfoResponse) assert GetExecutionInfoResponse.__mro__[1].__name__ == "GetExecutionInfoResponseExtensions" def test_from_dict_parses_artifacts(self): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index d585098..a65aba0 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import json import os import subprocess @@ -7,6 +8,9 @@ import zipfile from pathlib import Path +from packaging.specifiers import SpecifierSet +from packaging.version import Version + from tangle_cli.openapi import codegen @@ -98,7 +102,7 @@ def _write_consumer_tangle_api(path: Path) -> Path: return source_root -def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: +def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api(tmp_path) -> None: wheel = _build_wheel(tmp_path) stubs = tmp_path / "stubs" _write_import_stubs(stubs) @@ -113,9 +117,9 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Version: 0.0.1a2" in metadata - assert "Requires-Dist: tangle-api==0.0.1a2" not in requires_dist - assert "Requires-Dist: tangle-api==0.0.1a2 ; extra == 'native'" in requires_dist + assert "Version: 0.0.1a3" in metadata + assert "Requires-Dist: tangle-api==0.0.1a3" in requires_dist + assert not any("extra == 'native'" in line for line in requires_dist) assert "Provides-Extra: native" in metadata assert "tangle = tangle_cli.cli:main" in entry_points assert "tangle-cli = tangle_cli.cli:main" in entry_points @@ -141,7 +145,11 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: ) -def test_tangle_cli_wheel_api_refresh_builds_without_native_tangle_api(tmp_path) -> None: +def test_custom_tangle_api_local_version_can_satisfy_cli_pin() -> None: + assert Version("0.0.1a3+yourorg") in SpecifierSet("==0.0.1a3") + + +def test_tangle_cli_wheel_api_refresh_builds_in_expert_no_deps_fallback(tmp_path) -> None: wheel = _build_wheel(tmp_path) stubs = tmp_path / "stubs" _write_import_stubs(stubs) @@ -167,14 +175,15 @@ def test_tangle_cli_wheel_api_refresh_builds_without_native_tangle_api(tmp_path) ) -def test_tangle_cli_wheel_binds_to_consumer_local_tangle_api(tmp_path) -> None: +def test_tangle_cli_wheel_binds_to_project_local_tangle_api_before_official_package(tmp_path) -> None: cli_wheel = _build_wheel(tmp_path / "cli") + api_wheel = _build_wheel(tmp_path / "api", "--package", "tangle-api") consumer_source = _write_consumer_tangle_api(tmp_path / "consumer") stubs = tmp_path / "stubs" _write_runtime_stubs(stubs) env = { **os.environ, - "PYTHONPATH": os.pathsep.join([str(consumer_source), str(cli_wheel), str(stubs)]), + "PYTHONPATH": os.pathsep.join([str(consumer_source), str(cli_wheel), str(api_wheel), str(stubs)]), } subprocess.run( @@ -188,8 +197,9 @@ def test_tangle_cli_wheel_binds_to_consumer_local_tangle_api(tmp_path) -> None: "import tangle_cli.models as domain_models; " "client = TangleApiClient('https://api.test'); " "assert client.consumer_generated_marker() == 'consumer-local-operations'; " - "assert client_module.ComponentSpec is generated_models.ComponentSpec; " - "assert domain_models.ComponentSpec is generated_models.ComponentSpec; " + "assert client_module.ComponentSpec is domain_models.ComponentSpec; " + "assert issubclass(domain_models.ComponentSpec, generated_models.ComponentSpec); " + "assert domain_models.ComponentSpec.source == 'consumer-local'; " "assert generated_models.ComponentSpec.source == 'consumer-local'; " "assert generated_models.__file__.startswith(%r)" % str(consumer_source), ], @@ -237,7 +247,7 @@ def test_codegen_output_imports_as_consumer_local_tangle_api(tmp_path) -> None: encoding="utf-8", ) - codegen.generate(openapi, generated_dir, model_extension_module="") + codegen.generate(openapi, generated_dir) env = {**os.environ, "PYTHONPATH": str(source_root)} subprocess.run( @@ -262,7 +272,59 @@ def test_codegen_output_imports_as_consumer_local_tangle_api(tmp_path) -> None: ) -def test_native_wheels_provide_static_client_binding(tmp_path) -> None: +def test_tangle_api_source_has_no_tangle_cli_imports() -> None: + source_root = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" + for source in source_root.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + imported = [node.module or ""] + else: + continue + assert not any( + name == "tangle_cli" or name.startswith("tangle_cli.") + for name in imported + ), f"{source} imports {imported}" + + +def test_tangle_api_wheel_metadata_and_import_are_leaf(tmp_path) -> None: + api_wheel = _build_wheel(tmp_path / "api", "--package", "tangle-api") + with zipfile.ZipFile(api_wheel) as archive: + metadata_name = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + metadata = archive.read(metadata_name).decode() + + requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] + assert "Requires-Dist: pydantic>=2.0" in requires_dist + assert not any("tangle-cli" in line for line in requires_dist) + + env = {**os.environ, "PYTHONPATH": str(api_wheel)} + subprocess.run( + [ + sys.executable, + "-c", + "import importlib.abc\n" + "import sys\n" + "class BlockTangleCli(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path=None, target=None):\n" + " if fullname == 'tangle_cli' or fullname.startswith('tangle_cli.'):\n" + " raise ModuleNotFoundError('blocked tangle_cli import')\n" + " return None\n" + "sys.meta_path.insert(0, BlockTangleCli()); " + "import tangle_api.generated.models as models; " + "assert models.ComponentSpec.__name__ == 'ComponentSpec'; " + "assert not any(name == 'tangle_cli' or name.startswith('tangle_cli.') for name in sys.modules)", + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) + + +def test_default_wheels_provide_static_client_binding(tmp_path) -> None: cli_wheel = _build_wheel(tmp_path / "cli") api_wheel = _build_wheel(tmp_path / "api", "--package", "tangle-api") with zipfile.ZipFile(api_wheel) as archive: @@ -273,8 +335,9 @@ def test_native_wheels_provide_static_client_binding(tmp_path) -> None: metadata = archive.read(metadata_name).decode() requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] - assert "Version: 0.0.1a2" in metadata - assert "Requires-Dist: tangle-cli==0.0.1a2" in requires_dist + assert "Version: 0.0.1a3" in metadata + assert "Requires-Dist: pydantic>=2.0" in requires_dist + assert not any("tangle-cli" in line for line in requires_dist) env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} subprocess.run( diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 602df26..9668b13 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -9,14 +9,14 @@ from tangle_cli.client import TangleApiClient from tangle_cli.logger import CaptureLogger from tangle_api.generated.models import ( - GetExecutionInfoResponse, - GetGraphExecutionStateResponse, + GetExecutionInfoResponse as BaseGetExecutionInfoResponse, + GetGraphExecutionStateResponse as BaseGetGraphExecutionStateResponse, ListPublishedComponentsResponse, PipelineRunResponse, PublishedComponentResponse, SecretInfoResponse, ) -from tangle_cli.models import ComponentSpec +from tangle_cli.models import ComponentSpec, GetExecutionInfoResponse, GetGraphExecutionStateResponse def response(payload: Any = None, status_code: int = 200) -> requests.Response: @@ -43,7 +43,8 @@ def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: return response({}) -def test_generated_graph_state_response_extensions_work_at_runtime() -> None: +def test_cli_graph_state_response_extensions_work_at_runtime() -> None: + assert issubclass(GetGraphExecutionStateResponse, BaseGetGraphExecutionStateResponse) state = GetGraphExecutionStateResponse.from_dict({ "child_execution_status_stats": { "exec-1": {"SUCCEEDED": 2, "FAILED": 1}, @@ -127,6 +128,26 @@ def test_public_static_client_import_and_generated_operation() -> None: assert session.calls[0]["url"] == "https://api.test/api/pipeline_runs/run%2F1" +def test_static_client_generated_operations_use_cli_model_lookup_override() -> None: + session = FakeSession([ + response({ + "id": "exec-1", + "task_spec": {"componentRef": {"spec": {"name": "task"}}}, + "input_artifacts": {}, + "output_artifacts": {}, + }) + ]) + client = TangleApiClient("https://api.test", session=session) + + details = client.executions_details("exec-1") + + assert isinstance(details, GetExecutionInfoResponse) + assert isinstance(details, BaseGetExecutionInfoResponse) + assert details.__class__ is GetExecutionInfoResponse + assert details.tasks == {} + assert details.raw["id"] == "exec-1" + + def test_request_json_instantiates_list_response_models() -> None: session = FakeSession([ response([{"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}]) diff --git a/uv.lock b/uv.lock index 5821114..635da2b 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T17:04:09.933107Z" +exclude-newer = "2026-06-26T13:32:13.031764Z" exclude-newer-span = "P7D" [manifest] @@ -1775,22 +1775,18 @@ wheels = [ [[package]] name = "tangle-api" -version = "0.0.1a2" +version = "0.0.1a3" source = { editable = "packages/tangle-api" } dependencies = [ { name = "pydantic" }, - { name = "tangle-cli" }, ] [package.metadata] -requires-dist = [ - { name = "pydantic", specifier = ">=2.0" }, - { name = "tangle-cli", editable = "." }, -] +requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [[package]] name = "tangle-cli" -version = "0.0.1a2" +version = "0.0.1a3" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, @@ -1802,12 +1798,8 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, { name = "requests" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] - -[package.optional-dependencies] -native = [ { name = "tangle-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.dev-dependencies] @@ -1841,7 +1833,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.0" }, - { name = "tangle-api", marker = "extra == 'native'", editable = "packages/tangle-api" }, + { name = "tangle-api", editable = "packages/tangle-api" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, ] provides-extras = ["native"]