diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ad9b281 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Require Alexey Volkov and Volv Grebennikov to review changes in this repository. +* @Ark-kun @Volv-G diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dfd5787 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Check lockfile + run: uv lock --check + + - name: Check whitespace + run: git diff --check + + - name: Run tests + run: uv run pytest diff --git a/.gitignore b/.gitignore index b7faf40..09be0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..937f75f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "third_party/tangle"] + path = third_party/tangle + url = https://github.com/TangleML/tangle + branch = master diff --git a/README.md b/README.md index 4c23a30..7dd3b8c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,536 @@ # tangle-cli -[WIP] CLI for Tangle, the open-source ML pipeline orchestration platform + +CLI for Tangle, the open-source ML pipeline orchestration platform. + +This repository contains the public Tangle CLI package. The CLI is built with [Cyclopts](https://cyclopts.readthedocs.io/) and is intentionally split into two command families: + +- `tangle api ...` — pure OpenAPI wrappers around Tangle backend endpoints. +- `tangle sdk ...` — hand-written SDK, local, and compound commands that may call the API or may run entirely locally. + +Start here: + +```bash +uv run tangle quickstart +uv run tangle --help +uv run tangle api --help +uv run tangle sdk --help +``` + +## Command families + +### `tangle api ...`: direct OpenAPI wrappers + +`tangle api` commands are generated/dynamic wrappers for backend HTTP endpoints. They are useful when you want to call the API directly with minimal CLI behavior layered on top. + +API command sources are: + +- **Official static schema**: the checked-in OpenAPI snapshot packaged in `tangle_api.schema` and generated into `tangle_api.generated`. +- **Dynamic cache**: live schemas fetched with `tangle api refresh` and merged in by default as cached-only extension commands. + +By default `tangle api` uses `--schema-source auto`, which means official static operations plus cached live-backend extensions when a cache exists. Official operations win if a cached schema has the same method/path. + +### `tangle sdk ...`: hand-written SDK commands + +`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; +- **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: + +```bash +uv run tangle sdk artifacts --help +uv run tangle sdk components --help +uv run tangle sdk pipelines --help +uv run tangle sdk pipeline-runs --help +uv run tangle sdk published-components --help +uv run tangle sdk secrets --help +``` + +## Common parameters and environment + +API-backed commands commonly accept these options. Explicit CLI options win over config-file values, and config-file values win over environment defaults. + +| Option / env | Purpose | +| --- | --- | +| `--base-url`, `TANGLE_API_URL` | API origin. Defaults to local development API URL when omitted. | +| `--token`, `TANGLE_API_TOKEN` | Bearer token shorthand. | +| `--auth-header`, `TANGLE_API_AUTH_HEADER`, `TANGLE_AUTH_HEADER` | Full `Authorization` value such as `Bearer ...` or `Basic ...`. | +| `-H`, `--header`, `TANGLE_API_HEADERS` | Extra headers. Repeatable as CLI flags; env accepts a JSON object or newline-separated `Name: value` entries. | +| `--config` | YAML/JSON defaults. Many commands accept a single object, a list of objects, or `_defaults` + `configs`. | +| `--log-type` | SDK progress logs: `console`, `none`, or `file`. Logs go to stderr or a temp log file so structured stdout stays parseable. | +| `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics only. This is separate from normal progress logging. | + +Examples for protected APIs: + +```bash +uv run tangle api refresh --base-url https://api.example \ + --auth-header 'Bearer ...' \ + -H 'X-Gateway-Auth: ...' + +uv run tangle api pipeline-runs list --base-url https://api.example \ + --auth-header 'Basic ...' \ + -H 'X-Api-Key: ...' + +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --base-url https://api.example \ + --auth-header 'Bearer ...' \ + -H 'X-Gateway-Auth: ...' \ + --log-type console +``` + +Use `--log-type none` for quiet machine-readable runs, and `--log-type file` to capture progress logs in a temporary file while keeping stdout clean. + +## Installation and package split + +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. + +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: + +```bash +pip install 'tangle-cli[native]' +``` + +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. + +## Quick command examples + +Local-only SDK commands: + +```bash +uv run tangle sdk pipelines validate pipeline.yaml +uv run tangle sdk pipelines diagram pipeline.yaml +uv run tangle sdk pipelines layout pipeline.yaml --recursive +uv run tangle sdk pipelines hydrate pipeline.yaml --output hydrated.yaml +uv run tangle sdk components generate from-python path/to/component.py --image python:3.12 +uv run tangle sdk components bump-version path/to/component.yaml +``` + +API-backed SDK commands: + +```bash +uv run tangle sdk published-components search transformer --base-url https://api.example +uv run tangle sdk published-components inspect transformer --base-url https://api.example +uv run tangle sdk published-components publish components/my-component.yaml --dry-run +uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run --log-type none +uv run tangle sdk pipeline-runs submit pipeline.yaml --base-url https://api.example --log-type console +uv run tangle sdk pipeline-runs status RUN_ID --base-url https://api.example +uv run tangle sdk artifacts get --run-id RUN_ID --query '{"artifact_ids":["artifact-id"]}' +uv run tangle sdk secrets list --base-url https://api.example +``` + +Direct API commands: + +```bash +uv run tangle api refresh --base-url https://api.example +uv run tangle api pipeline-runs list --base-url https://api.example +uv run tangle api pipeline-runs get RUN_ID --base-url https://api.example +uv run tangle api components get DIGEST --base-url https://api.example +uv run tangle api published-components list --base-url https://api.example +``` + +Path parameters are positional arguments and query parameters become options. Check generated help for the exact options exposed by the active schema source: + +```bash +uv run tangle api pipeline-runs list --help +uv run tangle api pipeline-runs list --include-execution-stats +uv run tangle api pipeline-runs create --body @pipeline-run.json +``` + +Responses are printed as JSON when the backend returns JSON. + +## Config files + +Implemented API-backed commands and many SDK commands accept `--config path/to/config.yaml` (or JSON). Config files may contain a single object, a list of objects, or a `_defaults` + `configs` object; with multiple config entries, the command runs once per entry. + +```yaml +_defaults: + base_url: https://api.example + auth_header: Bearer ... + header: + - "X-Gateway-Auth: ..." + log_type: none + +configs: + - filter: active + limit: 10 + - filter: finished +``` + +```bash +uv run tangle api pipeline-runs list --config api-config.yaml --limit 5 +uv run tangle sdk published-components search --config components.yaml +uv run tangle sdk pipeline-runs submit --config submit.yaml +``` + +For generated `tangle api` commands, config keys use generated CLI parameter names such as `base_url`, `schema_source`, `body`, and endpoint parameters like `limit`, `filter`, or `id`. + +## API schema cache and dynamic commands + +Refresh the local schema cache for a live backend with: + +```bash +uv run tangle api refresh --base-url http://localhost:8000 +uv run tangle api refresh --base-url https://api.example --auth-header 'Bearer ...' +``` + +`refresh` fetches: + +```text +/openapi.json +``` + +Schemas are cached under the OS-specific user cache directory via `platformdirs`, with an `openapi` subdirectory. Override that directory with: + +```bash +export TANGLE_CLI_CACHE_DIR=/path/to/openapi-schema-cache +``` + +Delete a cached live schema without touching the checked-in official snapshot: + +```bash +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. + +For resource help, put `--schema-source` on the resource group: + +```bash +uv run tangle api published-components --schema-source official --help +uv run tangle api published-components --schema-source cache --help +``` + +For endpoint calls, put it on the endpoint command: + +```bash +uv run tangle api published-components experimental-search \ + --schema-source cache \ + --base-url https://api.example \ + --body @query.json +``` + +## SDK command details + +### Local components + +`generate from-python` converts a local Python function into a component YAML using inline source by default, or `--mode bundle` to embed local dependency modules. Common options include `--function`, `--output`, `--name`, `--image`, `--dependencies-from`, `--strip-code`, `--use-legacy-naming`, and `--resolve-root`. + +`bump-version` increments or sets component version metadata in YAML and updates/regenerates a referenced Python source when the component contains `python_original_code_path` annotations. + +Generation and version-bump commands accept `--config` YAML/JSON files via `tangle_cli.args_container`. Use keys such as `python_file`, `image`, `function`, `mode`, `resolve_root`, `yaml_file`, `set_version`, and `update_timestamp`; explicit CLI values take precedence. + +### Published components + +Published/registry component operations live under `sdk published-components` so local component authoring and registry calls do not share a command group. + +```bash +uv run tangle sdk published-components publish components/my-component.yaml \ + --base-url https://api.example \ + --image python:3.12 \ + --name "My component" + +uv run tangle sdk published-components publish components/my-component.yaml --dry-run +uv run tangle sdk published-components deprecate sha256:old --superseded-by sha256:new +``` + +`publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined. + +There is no separate OSS `publish-all` command. To publish multiple components, pass a YAML/JSON config list, or `_defaults` + `configs`, to the same `published-components publish` command; the command aggregates results and exits nonzero if any component errors. + +```yaml +_defaults: + base_url: https://api.example + image: python:3.12 +configs: + - component_path: components/first.yaml + name: First component + - component_path: components/second.yaml + name: Second component +``` + +Batch `publish-all`, notification integrations, dbt generation, from-container generation, and backend-specific advanced search workflows remain out of this OSS CLI package. + +### Pipelines and pipeline runs + +Local pipeline commands live under `sdk pipelines`: + +```bash +uv run tangle sdk pipelines validate pipeline.yaml +uv run tangle sdk pipelines hydrate pipeline.yaml --output hydrated.yaml +uv run tangle sdk pipelines diagram pipeline.yaml +uv run tangle sdk pipelines layout pipeline.yaml --recursive +``` + +Pipeline run API/submit commands live under `sdk pipeline-runs`: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run +uv run tangle sdk pipeline-runs submit pipeline.yaml --arg key=value --annotation owner=team +uv run tangle sdk pipeline-runs wait RUN_ID --max-wait 600 --poll-interval 10 +uv run tangle sdk pipeline-runs logs EXECUTION_ID +uv run tangle sdk pipeline-runs annotations set RUN_ID key value +uv run tangle sdk pipeline-runs export RUN_ID --output pipeline.yaml +``` + +`submit` hydrates refs by default and builds an API submit payload with `root_task.componentRef.spec`. Use `--no-hydrate` to submit the local YAML structure as-is. Use `--dry-run` to print the payload without creating a run. + +## Programmatic client + +The stable public wrapper for downstream Python tools is: + +```python +from tangle_cli.client import TangleApiClient + +client = TangleApiClient("http://localhost:8000") +run = client.pipeline_runs_get("run-id") +existing = client.find_existing_components( + ["component-name"], + published_by_substring="alice@example.com", +) +``` + +`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`. + +## Codegen/autogen from OpenAPI + +Use codegen when you want to update the checked-in official generated package or generate bindings for your own Tangle-compatible API instance. + +Official backend/submodule flow: + +```bash +git submodule update --init --recursive +uv sync --group codegen +uv run --group codegen python -m tangle_cli.openapi.codegen +uv run pytest +``` + +With no source flags, codegen loads OpenAPI from the default official backend submodule at `third_party/tangle`, writes `packages/tangle-api/src/tangle_api/schema/openapi.json`, and regenerates `packages/tangle-api/src/tangle_api/generated`. The backend import creates a database engine at import time; codegen points it at a temporary SQLite database unless `--backend-database-uri` is provided. + +Regenerate from the checked-in API-package snapshot: + +```bash +uv run python -m tangle_cli.openapi.codegen --from-snapshot +``` + +Fetch a remote OpenAPI JSON document directly: + +```bash +uv run python -m tangle_cli.openapi.codegen \ + --openapi-url https://api.example/openapi.json \ + --out src/tangle_api/generated +``` + +Generate from a backend checkout explicitly: + +```bash +uv run --group codegen python -m tangle_cli.openapi.codegen \ + --backend-path /path/to/tangle/backend \ + --backend-database-uri sqlite:////tmp/tangle-openapi.sqlite +``` + +Important codegen options: + +- `--out`: directory that receives `__init__.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. +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 + +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: + +```python +class _ComponentSpecGenerated(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: + +```python +MODEL_EXTENSIONS = { + "ComponentSpec": "ComponentSpecExtensions", + "GetExecutionInfoResponse": "GetExecutionInfoResponseExtensions", + "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", +} +``` + +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 "" +``` + +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`. + +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. + +## Extending SDK behavior + +The CLI exposes small explicit seams rather than requiring downstream forks. + +### Hydrator resolvers + +`packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py` exposes a resolver registry: + +```python +from tangle_cli.pipeline_hydrator import PipelineHydrator, register_component_resolver + + +def resolve_from_catalog(hydrator: PipelineHydrator, value, path: str, base_dir): + # return (digest, component_spec_dict) or None + return "sha256:...", {"name": "Resolved", "implementation": {"container": {"image": "python:3.12"}}} + +register_component_resolver("catalog", resolve_from_catalog) +``` + +Resolvers receive the hydrator instance, the reference value, a display path, and the current base directory. They can use `hydrator._api_client()` for API-backed lookups, `hydrator.log` for progress logs, and `hydrator.resolution_overrides` for template/config variables. There is also an instance method `hydrator.register_component_resolver(...)` for per-hydrator overrides. Built-in kinds include `digest`, `name`, `url`, `file`, `resolve`, `http`, `https`, `local`, and `local_from_python`. + +Downstream-only features such as Docker/from-container materialization or cloud storage can be added by registering new resolvers while the OSS default remains explicit about unsupported kinds. + +### Pipeline run hooks + +`packages/tangle-cli/src/tangle_cli/pipeline_runs.py` defines `PipelineRunHooks`, passed into `PipelineRunManager`. Subclass it to customize submit/load/wait/log behavior: + +```python +from tangle_cli.pipeline_runs import PipelineRunHooks, PipelineRunManager + + +class MyRunHooks(PipelineRunHooks): + def read_pipeline_yaml(self, pipeline_path): + if str(pipeline_path).startswith("s3://"): + return load_from_s3(pipeline_path) + return super().read_pipeline_yaml(pipeline_path) + + def extra_submit_annotations(self, *, pipeline_spec, pipeline_path, run_as=None): + annotations = super().extra_submit_annotations( + pipeline_spec=pipeline_spec, + pipeline_path=pipeline_path, + run_as=run_as, + ) + annotations["submitted_by"] = "my-tool" + return annotations + + def fetch_logs(self, client, execution_id): + return client.executions_container_log(execution_id) + +manager = PipelineRunManager(client=my_client, hooks=MyRunHooks()) +``` + +Available hooks include: + +- `read_pipeline_yaml(...)` +- `hydrate_pipeline(...)` +- `prepare_run_arguments(...)` +- `extra_submit_annotations(...)` +- `before_submit(...)` +- `after_submit(...)` +- `after_wait(...)` +- `fetch_logs(...)` + +Use these for generic downstream behavior such as alternate storage, extra annotations, scheduling/time input defaults, mutex checks, notifications, or alternate log providers. The OSS defaults intentionally exclude provider-specific cloud, notification, and scheduler behavior. + +### Component publish hooks + +`packages/tangle-cli/src/tangle_cli/component_publisher.py` defines `ComponentPublishHook` with: + +- `before_batch(components_config)` +- `after_component(component_path, result)` +- `after_batch(results)` + +`ComponentPublisher(..., hooks=[...])` calls these around publish batches. Use them for downstream summaries, audit records, or notifications while keeping OSS publishing generic. + +### Shared CLI helpers and logging + +`cli_options.py` centralizes shared Cyclopts annotations such as `BaseUrlOption`, `TokenOption`, `AuthHeaderOption`, `HeaderOption`, `ConfigOption`, and `LogTypeOption`. `cli_helpers.py` centralizes config loading, JSON printing, credential-isolation helpers, and the native-safe `LazyTangleApiClient` proxy. `logger.py` provides `ConsoleLogger`, `NullLogger`, `CaptureLogger`, `logger_for_log_type(...)`, and `run_with_logging(...)`. + +Use these helpers for new SDK commands so top-level imports remain native-free, `--config` behavior stays consistent, credentials from config do not accidentally mix with ambient environment auth, and progress logs stay off structured stdout. + +## Development checks + +Common validation commands: + +```bash +uv run --frozen pytest -q +uv build --sdist --wheel +uv build --sdist --wheel --package tangle-api +git diff --check +``` + +Targeted CLI smoke: + +```bash +uv run tangle quickstart +uv run tangle api --help +uv run tangle sdk --help +``` diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml new file mode 100644 index 0000000..8fd377f --- /dev/null +++ b/packages/tangle-api/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "tangle-api" +version = "0.1.0" +description = "Checked-in generated Tangle API models and operation proxies" +readme = "../../README.md" +authors = [ + { name = "Alexey Volkov", email = "alexey.volkov@ark-kun.com" }, + { name = "Tangle authors" }, +] +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.0", + "tangle-cli==0.1.0", +] + +[build-system] +requires = ["uv_build>=0.11.2,<0.12.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-root = "src" diff --git a/tangle_cli/__init__.py b/packages/tangle-api/src/tangle_api/__init__.py similarity index 100% rename from tangle_cli/__init__.py rename to packages/tangle-api/src/tangle_api/__init__.py diff --git a/packages/tangle-api/src/tangle_api/generated/__init__.py b/packages/tangle-api/src/tangle_api/generated/__init__.py new file mode 100644 index 0000000..2570bb6 --- /dev/null +++ b/packages/tangle-api/src/tangle_api/generated/__init__.py @@ -0,0 +1 @@ +"""Generated OpenAPI support modules.""" diff --git a/packages/tangle-api/src/tangle_api/generated/models.py b/packages/tangle-api/src/tangle_api/generated/models.py new file mode 100644 index 0000000..b6cbe7d --- /dev/null +++ b/packages/tangle-api/src/tangle_api/generated/models.py @@ -0,0 +1,470 @@ +"""Generated Pydantic models for the checked-in Tangle OpenAPI schema. + +Do not edit by hand; run ``uv run python -m tangle_cli.openapi.codegen``. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from tangle_cli.generated_runtime import TangleGeneratedModel + +from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetExecutionInfoResponseExtensions, GetGraphExecutionStateResponseExtensions + +class _ArtifactDataGenerated(TangleGeneratedModel): + created_at: Any = None + deleted_at: Any = None + extra_data: Any = None + hash: Any = None + is_dir: Any = None + total_size: Any = None + uri: Any = None + value: Any = None + +class ArtifactData(_ArtifactDataGenerated): + pass + +class _ArtifactDataResponseGenerated(TangleGeneratedModel): + is_dir: Any = None + total_size: Any = None + uri: Any = None + value: Any = None + +class ArtifactDataResponse(_ArtifactDataResponseGenerated): + pass + +class _ArtifactNodeIdResponseGenerated(TangleGeneratedModel): + id: Any = None + +class ArtifactNodeIdResponse(_ArtifactNodeIdResponseGenerated): + pass + +class _ArtifactNodeResponseGenerated(TangleGeneratedModel): + artifact_data: Any = None + id: Any = None + producer_execution_id: Any = None + producer_output_name: Any = None + type_name: Any = None + type_properties: Any = None + +class ArtifactNodeResponse(_ArtifactNodeResponseGenerated): + pass + +class _BodyCreateApiPipelineRunsPostGenerated(TangleGeneratedModel): + annotations: Any = None + components: Any = None + root_task: Any = None + +class BodyCreateApiPipelineRunsPost(_BodyCreateApiPipelineRunsPostGenerated): + pass + +class _BodyCreateSecretApiSecretsPostGenerated(TangleGeneratedModel): + secret_value: Any = None + +class BodyCreateSecretApiSecretsPost(_BodyCreateSecretApiSecretsPostGenerated): + pass + +class _BodySetSettingsApiUsersMeSettingsPatchGenerated(TangleGeneratedModel): + settings: Any = None + +class BodySetSettingsApiUsersMeSettingsPatch(_BodySetSettingsApiUsersMeSettingsPatchGenerated): + pass + +class _BodyUpdateSecretApiSecretsSecretNamePutGenerated(TangleGeneratedModel): + secret_value: Any = None + +class BodyUpdateSecretApiSecretsSecretNamePut(_BodyUpdateSecretApiSecretsSecretNamePutGenerated): + pass + +class _CachingStrategySpecGenerated(TangleGeneratedModel): + maxcachestaleness: Any = Field(default=None, alias='maxCacheStaleness') + +class CachingStrategySpec(_CachingStrategySpecGenerated): + pass + +class _ComponentLibraryGenerated(TangleGeneratedModel): + annotations: Any = None + name: Any = None + root_folder: Any = None + +class ComponentLibrary(_ComponentLibraryGenerated): + pass + +class _ComponentLibraryFolderGenerated(TangleGeneratedModel): + annotations: Any = None + components: Any = None + folders: Any = None + name: Any = None + +class ComponentLibraryFolder(_ComponentLibraryFolderGenerated): + pass + +class _ComponentLibraryResponseGenerated(TangleGeneratedModel): + annotations: Any = None + component_count: Any = None + created_at: Any = None + hide_from_search: Any = None + id: Any = None + name: Any = None + published_by: Any = None + root_folder: Any = None + updated_at: Any = None + +class ComponentLibraryResponse(_ComponentLibraryResponseGenerated): + pass + +class _ComponentReferenceGenerated(TangleGeneratedModel): + digest: Any = None + name: Any = None + spec: Any = None + tag: Any = None + text: Any = None + url: Any = None + +class ComponentReference(_ComponentReferenceGenerated): + pass + +class _ComponentResponseGenerated(TangleGeneratedModel): + digest: Any = None + text: Any = None + +class ComponentResponse(_ComponentResponseGenerated): + pass + +class _ComponentSpecGenerated(TangleGeneratedModel): + description: Any = None + implementation: Any = None + inputs: Any = None + metadata: Any = None + name: Any = None + outputs: Any = None + +class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): + pass + +class _ConcatPlaceholderGenerated(TangleGeneratedModel): + concat: Any = None + +class ConcatPlaceholder(_ConcatPlaceholderGenerated): + pass + +ContainerExecutionStatus = Any + +class _ContainerImplementationGenerated(TangleGeneratedModel): + container: Any = None + +class ContainerImplementation(_ContainerImplementationGenerated): + pass + +class _ContainerSpecGenerated(TangleGeneratedModel): + args: Any = None + command: Any = None + env: Any = None + image: Any = None + +class ContainerSpec(_ContainerSpecGenerated): + pass + +class _DynamicDataArgumentGenerated(TangleGeneratedModel): + dynamicdata: Any = Field(default=None, alias='dynamicData') + +class DynamicDataArgument(_DynamicDataArgumentGenerated): + pass + +class _ExecutionNodeReferenceGenerated(TangleGeneratedModel): + execution_node_id: Any = None + pipeline_run_id: Any = None + +class ExecutionNodeReference(_ExecutionNodeReferenceGenerated): + pass + +class _ExecutionOptionsSpecGenerated(TangleGeneratedModel): + cachingstrategy: Any = Field(default=None, alias='cachingStrategy') + retrystrategy: Any = Field(default=None, alias='retryStrategy') + +class ExecutionOptionsSpec(_ExecutionOptionsSpecGenerated): + pass + +class _ExecutionStatusSummaryGenerated(TangleGeneratedModel): + ended_executions: Any = None + has_ended: Any = None + total_executions: Any = None + +class ExecutionStatusSummary(_ExecutionStatusSummaryGenerated): + pass + +class _GetArtifactInfoResponseGenerated(TangleGeneratedModel): + artifact_data: Any = None + id: Any = None + +class GetArtifactInfoResponse(_GetArtifactInfoResponseGenerated): + pass + +class _GetArtifactSignedUrlResponseGenerated(TangleGeneratedModel): + signed_url: Any = None + +class GetArtifactSignedUrlResponse(_GetArtifactSignedUrlResponseGenerated): + pass + +class _GetContainerExecutionLogResponseGenerated(TangleGeneratedModel): + log_text: Any = None + orchestration_error_message: Any = None + system_error_exception_full: Any = None + +class GetContainerExecutionLogResponse(_GetContainerExecutionLogResponseGenerated): + pass + +class _GetContainerExecutionStateResponseGenerated(TangleGeneratedModel): + debug_info: Any = None + ended_at: Any = None + execution_nodes_linked_to_same_container_execution: Any = None + exit_code: Any = None + started_at: Any = None + status: Any = None + +class GetContainerExecutionStateResponse(_GetContainerExecutionStateResponseGenerated): + pass + +class _GetExecutionArtifactsResponseGenerated(TangleGeneratedModel): + input_artifacts: Any = None + output_artifacts: Any = None + +class GetExecutionArtifactsResponse(_GetExecutionArtifactsResponseGenerated): + pass + +class _GetExecutionInfoResponseGenerated(TangleGeneratedModel): + child_task_execution_ids: Any = None + id: Any = None + input_artifacts: Any = None + output_artifacts: Any = None + parent_execution_id: Any = None + pipeline_run_id: Any = None + task_spec: Any = None + +class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, _GetExecutionInfoResponseGenerated): + pass + +class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel): + child_execution_status_stats: Any = None + child_execution_status_summary: Any = None + +class GetGraphExecutionStateResponse(GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated): + pass + +class _GetUserResponseGenerated(TangleGeneratedModel): + id: Any = None + permissions: Any = None + +class GetUserResponse(_GetUserResponseGenerated): + pass + +class _GraphImplementationGenerated(TangleGeneratedModel): + graph: Any = None + +class GraphImplementation(_GraphImplementationGenerated): + pass + +class _GraphInputArgumentGenerated(TangleGeneratedModel): + graphinput: Any = Field(default=None, alias='graphInput') + +class GraphInputArgument(_GraphInputArgumentGenerated): + pass + +class _GraphInputReferenceGenerated(TangleGeneratedModel): + inputname: Any = Field(default=None, alias='inputName') + type: Any = None + +class GraphInputReference(_GraphInputReferenceGenerated): + pass + +class _GraphSpecGenerated(TangleGeneratedModel): + outputvalues: Any = Field(default=None, alias='outputValues') + tasks: Any = None + +class GraphSpec(_GraphSpecGenerated): + pass + +class _HTTPValidationErrorGenerated(TangleGeneratedModel): + detail: Any = None + +class HTTPValidationError(_HTTPValidationErrorGenerated): + pass + +class _IfPlaceholderGenerated(TangleGeneratedModel): + if_: Any = Field(default=None, alias='if') + +class IfPlaceholder(_IfPlaceholderGenerated): + pass + +class _IfPlaceholderStructureGenerated(TangleGeneratedModel): + cond: Any = None + else_: Any = Field(default=None, alias='else') + then: Any = None + +class IfPlaceholderStructure(_IfPlaceholderStructureGenerated): + pass + +class _InputPathPlaceholderGenerated(TangleGeneratedModel): + inputpath: Any = Field(default=None, alias='inputPath') + +class InputPathPlaceholder(_InputPathPlaceholderGenerated): + pass + +class _InputSpecGenerated(TangleGeneratedModel): + annotations: Any = None + default: Any = None + description: Any = None + name: Any = None + optional: Any = None + type: Any = None + +class InputSpec(_InputSpecGenerated): + pass + +class _InputValuePlaceholderGenerated(TangleGeneratedModel): + inputvalue: Any = Field(default=None, alias='inputValue') + +class InputValuePlaceholder(_InputValuePlaceholderGenerated): + pass + +class _IsPresentPlaceholderGenerated(TangleGeneratedModel): + ispresent: Any = Field(default=None, alias='isPresent') + +class IsPresentPlaceholder(_IsPresentPlaceholderGenerated): + pass + +class _ListComponentLibrariesResponseGenerated(TangleGeneratedModel): + component_libraries: Any = None + +class ListComponentLibrariesResponse(_ListComponentLibrariesResponseGenerated): + pass + +class _ListPipelineJobsResponseGenerated(TangleGeneratedModel): + next_page_token: Any = None + pipeline_runs: Any = None + +class ListPipelineJobsResponse(_ListPipelineJobsResponseGenerated): + pass + +class _ListPublishedComponentsResponseGenerated(TangleGeneratedModel): + published_components: Any = None + +class ListPublishedComponentsResponse(_ListPublishedComponentsResponseGenerated): + pass + +class _ListSecretsResponseGenerated(TangleGeneratedModel): + secrets: Any = None + +class ListSecretsResponse(_ListSecretsResponseGenerated): + pass + +class _MetadataSpecGenerated(TangleGeneratedModel): + annotations: Any = None + labels: Any = None + +class MetadataSpec(_MetadataSpecGenerated): + pass + +class _OutputPathPlaceholderGenerated(TangleGeneratedModel): + outputpath: Any = Field(default=None, alias='outputPath') + +class OutputPathPlaceholder(_OutputPathPlaceholderGenerated): + pass + +class _OutputSpecGenerated(TangleGeneratedModel): + annotations: Any = None + description: Any = None + name: Any = None + type: Any = None + +class OutputSpec(_OutputSpecGenerated): + pass + +class _PipelineRunResponseGenerated(TangleGeneratedModel): + annotations: Any = None + created_at: Any = None + created_by: Any = None + execution_status_stats: Any = None + execution_summary: Any = None + id: Any = None + pipeline_name: Any = None + root_execution_id: Any = None + +class PipelineRunResponse(_PipelineRunResponseGenerated): + pass + +class _PublishedComponentResponseGenerated(TangleGeneratedModel): + deprecated: Any = None + digest: Any = None + name: Any = None + published_by: Any = None + superseded_by: Any = None + url: Any = None + +class PublishedComponentResponse(_PublishedComponentResponseGenerated): + pass + +class _RetryStrategySpecGenerated(TangleGeneratedModel): + maxretries: Any = Field(default=None, alias='maxRetries') + +class RetryStrategySpec(_RetryStrategySpecGenerated): + pass + +class _SecretInfoResponseGenerated(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): + taskoutput: Any = Field(default=None, alias='taskOutput') + +class TaskOutputArgument(_TaskOutputArgumentGenerated): + pass + +class _TaskOutputReferenceGenerated(TangleGeneratedModel): + outputname: Any = Field(default=None, alias='outputName') + taskid: Any = Field(default=None, alias='taskId') + +class TaskOutputReference(_TaskOutputReferenceGenerated): + pass + +class _TaskSpecGenerated(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): + component_library_ids: Any = None + +class UserComponentLibraryPinsResponse(_UserComponentLibraryPinsResponseGenerated): + pass + +class _UserSettingsResponseGenerated(TangleGeneratedModel): + settings: Any = None + +class UserSettingsResponse(_UserSettingsResponseGenerated): + pass + +class _ValidationErrorGenerated(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 new file mode 100644 index 0000000..c946eb9 --- /dev/null +++ b/packages/tangle-api/src/tangle_api/generated/operations.py @@ -0,0 +1,389 @@ +"""Generated static endpoint methods for the Tangle API. + +Do not edit by hand; run ``uv run python -m tangle_cli.openapi.codegen``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from .models import ComponentLibraryResponse, ComponentResponse, GetArtifactInfoResponse, GetArtifactSignedUrlResponse, GetContainerExecutionLogResponse, GetContainerExecutionStateResponse, GetExecutionArtifactsResponse, GetExecutionInfoResponse, GetGraphExecutionStateResponse, GetUserResponse, ListComponentLibrariesResponse, ListPipelineJobsResponse, ListPublishedComponentsResponse, ListSecretsResponse, PipelineRunResponse, PublishedComponentResponse, SecretInfoResponse, UserComponentLibraryPinsResponse, UserSettingsResponse + + +class GeneratedTangleApiOperations: + """Generated checked-in methods for Tangle API operations.""" + + if TYPE_CHECKING: + def _request_json( + self, + method: str, + path: str, + *, + path_params: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + json_data: Any = None, + response_model: Any = None, + ) -> Any: ... + + def admin_execution_node_status(self, id: Any, status: Any) -> None: + return self._request_json( + 'PUT', + '/api/admin/execution_node/{id}/status', + path_params={'id': id}, + params={'status': status}, + json_data=None, + response_model=None, + ) + + def admin_set_read_only_model(self, read_only: Any) -> None: + return self._request_json( + 'PUT', + '/api/admin/set_read_only_model', + path_params=None, + params={'read_only': read_only}, + json_data=None, + response_model=None, + ) + + def admin_sql_engine_connection_pool_status(self) -> str: + return self._request_json( + 'GET', + '/api/admin/sql_engine_connection_pool_status', + path_params=None, + params=None, + json_data=None, + response_model=None, + ) + + def artifacts_get(self, id: Any) -> GetArtifactInfoResponse: + return self._request_json( + 'GET', + '/api/artifacts/{id}', + path_params={'id': id}, + params=None, + json_data=None, + response_model=GetArtifactInfoResponse, + ) + + def artifacts_signed_artifact_url(self, id: Any) -> GetArtifactSignedUrlResponse: + return self._request_json( + 'GET', + '/api/artifacts/{id}/signed_artifact_url', + path_params={'id': id}, + params=None, + json_data=None, + response_model=GetArtifactSignedUrlResponse, + ) + + def component_libraries_list(self, name_substring: Any = None) -> ListComponentLibrariesResponse: + return self._request_json( + 'GET', + '/api/component_libraries/', + path_params=None, + params={'name_substring': name_substring}, + json_data=None, + response_model=ListComponentLibrariesResponse, + ) + + def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: + return self._request_json( + 'POST', + '/api/component_libraries/', + path_params=None, + params={'hide_from_search': hide_from_search}, + json_data={'name': name}, + response_model=ComponentLibraryResponse, + ) + + def component_libraries_get(self, id: Any, include_component_texts: Any = None) -> ComponentLibraryResponse: + return self._request_json( + 'GET', + '/api/component_libraries/{id}', + path_params={'id': id}, + params={'include_component_texts': include_component_texts}, + json_data=None, + response_model=ComponentLibraryResponse, + ) + + def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: + return self._request_json( + 'PUT', + '/api/component_libraries/{id}', + path_params={'id': id}, + params={'hide_from_search': hide_from_search}, + json_data={'name': name}, + response_model=ComponentLibraryResponse, + ) + + def component_library_pins_me(self) -> UserComponentLibraryPinsResponse: + return self._request_json( + 'GET', + '/api/component_library_pins/me/', + path_params=None, + params=None, + json_data=None, + response_model=UserComponentLibraryPinsResponse, + ) + + def component_library_pins_put_me(self, body: Any = None) -> None: + return self._request_json( + 'PUT', + '/api/component_library_pins/me/', + path_params=None, + params=None, + json_data=body, + response_model=None, + ) + + def components_get(self, digest: Any) -> ComponentResponse: + return self._request_json( + 'GET', + '/api/components/{digest}', + path_params={'digest': digest}, + params=None, + json_data=None, + response_model=ComponentResponse, + ) + + def executions_artifacts(self, id: Any) -> GetExecutionArtifactsResponse: + return self._request_json( + 'GET', + '/api/executions/{id}/artifacts', + path_params={'id': id}, + params=None, + json_data=None, + response_model=GetExecutionArtifactsResponse, + ) + + def executions_container_log(self, id: Any) -> GetContainerExecutionLogResponse: + return self._request_json( + 'GET', + '/api/executions/{id}/container_log', + path_params={'id': id}, + params=None, + json_data=None, + response_model=GetContainerExecutionLogResponse, + ) + + def executions_container_state(self, id: Any, include_execution_nodes_linked_to_same_container_execution: Any = None) -> GetContainerExecutionStateResponse: + return self._request_json( + 'GET', + '/api/executions/{id}/container_state', + 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, + ) + + def executions_details(self, id: Any) -> GetExecutionInfoResponse: + return self._request_json( + 'GET', + '/api/executions/{id}/details', + path_params={'id': id}, + params=None, + json_data=None, + response_model=GetExecutionInfoResponse, + ) + + def executions_graph_execution_state(self, id: Any) -> GetGraphExecutionStateResponse: + return self._request_json( + 'GET', + '/api/executions/{id}/graph_execution_state', + path_params={'id': id}, + params=None, + json_data=None, + response_model=GetGraphExecutionStateResponse, + ) + + def executions_state(self, id: Any) -> GetGraphExecutionStateResponse: + return self._request_json( + 'GET', + '/api/executions/{id}/state', + path_params={'id': id}, + params=None, + json_data=None, + response_model=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: + return self._request_json( + 'GET', + '/api/pipeline_runs/', + 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, + ) + + def pipeline_runs_create(self, body: Any = None) -> PipelineRunResponse: + return self._request_json( + 'POST', + '/api/pipeline_runs/', + path_params=None, + params=None, + json_data=body, + response_model=PipelineRunResponse, + ) + + def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> PipelineRunResponse: + return self._request_json( + 'GET', + '/api/pipeline_runs/{id}', + path_params={'id': id}, + params={'include_execution_stats': include_execution_stats}, + json_data=None, + response_model=PipelineRunResponse, + ) + + def pipeline_runs_annotations(self, id: Any) -> dict[str, Any]: + return self._request_json( + 'GET', + '/api/pipeline_runs/{id}/annotations/', + path_params={'id': id}, + params=None, + json_data=None, + response_model=None, + ) + + def pipeline_runs_put_annotations(self, id: Any, key: Any, value: Any = None) -> None: + return self._request_json( + 'PUT', + '/api/pipeline_runs/{id}/annotations/{key}', + path_params={'id': id, 'key': key}, + params={'value': value}, + json_data=None, + response_model=None, + ) + + def pipeline_runs_delete_annotations(self, id: Any, key: Any) -> None: + return self._request_json( + 'DELETE', + '/api/pipeline_runs/{id}/annotations/{key}', + path_params={'id': id, 'key': key}, + params=None, + json_data=None, + response_model=None, + ) + + def pipeline_runs_cancel(self, id: Any) -> None: + return self._request_json( + 'POST', + '/api/pipeline_runs/{id}/cancel', + path_params={'id': id}, + params=None, + json_data=None, + response_model=None, + ) + + def published_components_list(self, include_deprecated: Any = None, name_substring: Any = None, published_by_substring: Any = None, digest: Any = None) -> ListPublishedComponentsResponse: + return self._request_json( + 'GET', + '/api/published_components/', + 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, + ) + + def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None) -> PublishedComponentResponse: + return self._request_json( + 'POST', + '/api/published_components/', + 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, + ) + + def published_components_update(self, digest: Any, deprecated: Any = None, superseded_by: Any = None) -> PublishedComponentResponse: + return self._request_json( + 'PUT', + '/api/published_components/{digest}', + path_params={'digest': digest}, + params={'deprecated': deprecated, 'superseded_by': superseded_by}, + json_data=None, + response_model=PublishedComponentResponse, + ) + + def secrets_list(self) -> ListSecretsResponse: + return self._request_json( + 'GET', + '/api/secrets/', + path_params=None, + params=None, + json_data=None, + response_model=ListSecretsResponse, + ) + + def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: + return self._request_json( + 'POST', + '/api/secrets/', + path_params=None, + params={'secret_name': secret_name, 'description': description, 'expires_at': expires_at}, + json_data={'secret_value': secret_value}, + response_model=SecretInfoResponse, + ) + + def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: + return self._request_json( + 'PUT', + '/api/secrets/{secret_name}', + path_params={'secret_name': secret_name}, + params={'description': description, 'expires_at': expires_at}, + json_data={'secret_value': secret_value}, + response_model=SecretInfoResponse, + ) + + def secrets_delete(self, secret_name: Any) -> None: + return self._request_json( + 'DELETE', + '/api/secrets/{secret_name}', + path_params={'secret_name': secret_name}, + params=None, + json_data=None, + response_model=None, + ) + + def users_me(self) -> GetUserResponse | None: + return self._request_json( + 'GET', + '/api/users/me', + path_params=None, + params=None, + json_data=None, + response_model=GetUserResponse, + ) + + def users_me_settings(self, setting_names: Any = None) -> UserSettingsResponse: + return self._request_json( + 'GET', + '/api/users/me/settings', + path_params=None, + params={'setting_names': setting_names}, + json_data=None, + response_model=UserSettingsResponse, + ) + + def users_patch_me_settings(self, body: Any = None) -> None: + return self._request_json( + 'PATCH', + '/api/users/me/settings', + path_params=None, + params=None, + json_data=body, + response_model=None, + ) + + def users_delete_me_settings(self, setting_names: Any) -> None: + return self._request_json( + 'DELETE', + '/api/users/me/settings', + path_params=None, + params={'setting_names': setting_names}, + json_data=None, + response_model=None, + ) + +__all__ = ['GeneratedTangleApiOperations'] diff --git a/packages/tangle-api/src/tangle_api/py.typed b/packages/tangle-api/src/tangle_api/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/tangle-api/src/tangle_api/schema/__init__.py b/packages/tangle-api/src/tangle_api/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/tangle-api/src/tangle_api/schema/openapi.json b/packages/tangle-api/src/tangle_api/schema/openapi.json new file mode 100644 index 0000000..adf8e72 --- /dev/null +++ b/packages/tangle-api/src/tangle_api/schema/openapi.json @@ -0,0 +1,3881 @@ +{ + "components": { + "schemas": { + "ArtifactData": { + "properties": { + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "deleted_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deleted At" + }, + "extra_data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Extra Data" + }, + "hash": { + "title": "Hash", + "type": "string" + }, + "is_dir": { + "title": "Is Dir", + "type": "boolean" + }, + "total_size": { + "title": "Total Size", + "type": "integer" + }, + "uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Uri" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "total_size", + "is_dir", + "hash" + ], + "title": "ArtifactData", + "type": "object" + }, + "ArtifactDataResponse": { + "properties": { + "is_dir": { + "title": "Is Dir", + "type": "boolean" + }, + "total_size": { + "title": "Total Size", + "type": "integer" + }, + "uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Uri" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "total_size", + "is_dir" + ], + "title": "ArtifactDataResponse", + "type": "object" + }, + "ArtifactNodeIdResponse": { + "properties": { + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "ArtifactNodeIdResponse", + "type": "object" + }, + "ArtifactNodeResponse": { + "properties": { + "artifact_data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArtifactDataResponse" + }, + { + "type": "null" + } + ] + }, + "id": { + "title": "Id", + "type": "string" + }, + "producer_execution_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Producer Execution Id" + }, + "producer_output_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Producer Output Name" + }, + "type_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type Name" + }, + "type_properties": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Type Properties" + } + }, + "required": [ + "id" + ], + "title": "ArtifactNodeResponse", + "type": "object" + }, + "Body_create_api_pipeline_runs__post": { + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "components": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ComponentReference" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Components" + }, + "root_task": { + "$ref": "#/components/schemas/TaskSpec" + } + }, + "required": [ + "root_task" + ], + "title": "Body_create_api_pipeline_runs__post", + "type": "object" + }, + "Body_create_secret_api_secrets__post": { + "properties": { + "secret_value": { + "title": "Secret Value", + "type": "string" + } + }, + "required": [ + "secret_value" + ], + "title": "Body_create_secret_api_secrets__post", + "type": "object" + }, + "Body_set_settings_api_users_me_settings_patch": { + "properties": { + "settings": { + "additionalProperties": true, + "title": "Settings", + "type": "object" + } + }, + "required": [ + "settings" + ], + "title": "Body_set_settings_api_users_me_settings_patch", + "type": "object" + }, + "Body_update_secret_api_secrets__secret_name__put": { + "properties": { + "secret_value": { + "title": "Secret Value", + "type": "string" + } + }, + "required": [ + "secret_value" + ], + "title": "Body_update_secret_api_secrets__secret_name__put", + "type": "object" + }, + "CachingStrategySpec": { + "properties": { + "maxCacheStaleness": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Maxcachestaleness" + } + }, + "title": "CachingStrategySpec", + "type": "object" + }, + "ComponentLibrary": { + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "name": { + "title": "Name", + "type": "string" + }, + "root_folder": { + "$ref": "#/components/schemas/ComponentLibraryFolder" + } + }, + "required": [ + "name", + "root_folder" + ], + "title": "ComponentLibrary", + "type": "object" + }, + "ComponentLibraryFolder": { + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "components": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ComponentReference" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Components" + }, + "folders": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ComponentLibraryFolder" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Folders" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ComponentLibraryFolder", + "type": "object" + }, + "ComponentLibraryResponse": { + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "component_count": { + "default": 0, + "title": "Component Count", + "type": "integer" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "hide_from_search": { + "default": false, + "title": "Hide From Search", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "published_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Published By" + }, + "root_folder": { + "anyOf": [ + { + "$ref": "#/components/schemas/ComponentLibraryFolder" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "name", + "created_at", + "updated_at" + ], + "title": "ComponentLibraryResponse", + "type": "object" + }, + "ComponentReference": { + "description": "Component reference. Contains information that can be used to locate and load a component by name, digest or URL", + "properties": { + "digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Digest" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "spec": { + "anyOf": [ + { + "$ref": "#/components/schemas/ComponentSpec" + }, + { + "type": "null" + } + ] + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tag" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Text" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + } + }, + "title": "ComponentReference", + "type": "object" + }, + "ComponentResponse": { + "properties": { + "digest": { + "title": "Digest", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "digest", + "text" + ], + "title": "ComponentResponse", + "type": "object" + }, + "ComponentSpec": { + "description": "Component specification. Describes the metadata (name, description, annotations and labels), the interface (inputs and outputs) and the implementation of the component.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "implementation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ContainerImplementation" + }, + { + "$ref": "#/components/schemas/GraphImplementation" + }, + { + "type": "null" + } + ], + "title": "Implementation" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/InputSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Inputs" + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataSpec" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OutputSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Outputs" + } + }, + "title": "ComponentSpec", + "type": "object" + }, + "ConcatPlaceholder": { + "description": "Represents the command-line argument placeholder that will be replaced at run-time by the concatenated values of its items.", + "properties": { + "concat": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/InputValuePlaceholder" + }, + { + "$ref": "#/components/schemas/InputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/OutputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/ConcatPlaceholder" + }, + { + "$ref": "#/components/schemas/IfPlaceholder" + } + ] + }, + "title": "Concat", + "type": "array" + } + }, + "required": [ + "concat" + ], + "title": "ConcatPlaceholder", + "type": "object" + }, + "ContainerExecutionStatus": { + "enum": [ + "INVALID", + "UNINITIALIZED", + "QUEUED", + "WAITING_FOR_UPSTREAM", + "PENDING", + "RUNNING", + "SUCCEEDED", + "FAILED", + "SYSTEM_ERROR", + "CANCELLING", + "CANCELLED", + "SKIPPED" + ], + "title": "ContainerExecutionStatus", + "type": "string" + }, + "ContainerImplementation": { + "description": "Represents the container component implementation.", + "properties": { + "container": { + "$ref": "#/components/schemas/ContainerSpec" + } + }, + "required": [ + "container" + ], + "title": "ContainerImplementation", + "type": "object" + }, + "ContainerSpec": { + "description": "Describes the container component implementation.", + "properties": { + "args": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/InputValuePlaceholder" + }, + { + "$ref": "#/components/schemas/InputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/OutputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/ConcatPlaceholder" + }, + { + "$ref": "#/components/schemas/IfPlaceholder" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Args" + }, + "command": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/InputValuePlaceholder" + }, + { + "$ref": "#/components/schemas/InputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/OutputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/ConcatPlaceholder" + }, + { + "$ref": "#/components/schemas/IfPlaceholder" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Command" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "image": { + "title": "Image", + "type": "string" + } + }, + "required": [ + "image" + ], + "title": "ContainerSpec", + "type": "object" + }, + "DynamicDataArgument": { + "description": "Argument that references data that's dynamically produced by the execution system at runtime.\n\nExamples of dynamic data:\n* Secret value\n* Container execution ID\n* Pipeline run ID\n* Loop index/item", + "properties": { + "dynamicData": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + } + ], + "title": "Dynamicdata" + } + }, + "required": [ + "dynamicData" + ], + "title": "DynamicDataArgument", + "type": "object" + }, + "ExecutionNodeReference": { + "properties": { + "execution_node_id": { + "title": "Execution Node Id", + "type": "string" + }, + "pipeline_run_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pipeline Run Id" + } + }, + "required": [ + "execution_node_id", + "pipeline_run_id" + ], + "title": "ExecutionNodeReference", + "type": "object" + }, + "ExecutionOptionsSpec": { + "properties": { + "cachingStrategy": { + "anyOf": [ + { + "$ref": "#/components/schemas/CachingStrategySpec" + }, + { + "type": "null" + } + ] + }, + "retryStrategy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RetryStrategySpec" + }, + { + "type": "null" + } + ] + } + }, + "title": "ExecutionOptionsSpec", + "type": "object" + }, + "ExecutionStatusSummary": { + "properties": { + "ended_executions": { + "title": "Ended Executions", + "type": "integer" + }, + "has_ended": { + "title": "Has Ended", + "type": "boolean" + }, + "total_executions": { + "title": "Total Executions", + "type": "integer" + } + }, + "required": [ + "total_executions", + "ended_executions", + "has_ended" + ], + "title": "ExecutionStatusSummary", + "type": "object" + }, + "GetArtifactInfoResponse": { + "properties": { + "artifact_data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArtifactData" + }, + { + "type": "null" + } + ] + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "GetArtifactInfoResponse", + "type": "object" + }, + "GetArtifactSignedUrlResponse": { + "properties": { + "signed_url": { + "title": "Signed Url", + "type": "string" + } + }, + "required": [ + "signed_url" + ], + "title": "GetArtifactSignedUrlResponse", + "type": "object" + }, + "GetContainerExecutionLogResponse": { + "properties": { + "log_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Log Text" + }, + "orchestration_error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Orchestration Error Message" + }, + "system_error_exception_full": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "System Error Exception Full" + } + }, + "title": "GetContainerExecutionLogResponse", + "type": "object" + }, + "GetContainerExecutionStateResponse": { + "properties": { + "debug_info": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Debug Info" + }, + "ended_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ended At" + }, + "execution_nodes_linked_to_same_container_execution": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ExecutionNodeReference" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Execution Nodes Linked To Same Container Execution" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Exit Code" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "status": { + "$ref": "#/components/schemas/ContainerExecutionStatus" + } + }, + "required": [ + "status" + ], + "title": "GetContainerExecutionStateResponse", + "type": "object" + }, + "GetExecutionArtifactsResponse": { + "properties": { + "input_artifacts": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ArtifactNodeResponse" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Input Artifacts" + }, + "output_artifacts": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ArtifactNodeResponse" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Output Artifacts" + } + }, + "title": "GetExecutionArtifactsResponse", + "type": "object" + }, + "GetExecutionInfoResponse": { + "properties": { + "child_task_execution_ids": { + "additionalProperties": { + "type": "string" + }, + "title": "Child Task Execution Ids", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "input_artifacts": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ArtifactNodeIdResponse" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Input Artifacts" + }, + "output_artifacts": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ArtifactNodeIdResponse" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Output Artifacts" + }, + "parent_execution_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Execution Id" + }, + "pipeline_run_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pipeline Run Id" + }, + "task_spec": { + "$ref": "#/components/schemas/TaskSpec" + } + }, + "required": [ + "id", + "task_spec", + "child_task_execution_ids" + ], + "title": "GetExecutionInfoResponse", + "type": "object" + }, + "GetGraphExecutionStateResponse": { + "properties": { + "child_execution_status_stats": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "title": "Child Execution Status Stats", + "type": "object" + }, + "child_execution_status_summary": { + "$ref": "#/components/schemas/ExecutionStatusSummary" + } + }, + "required": [ + "child_execution_status_stats", + "child_execution_status_summary" + ], + "title": "GetGraphExecutionStateResponse", + "type": "object" + }, + "GetUserResponse": { + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "permissions": { + "items": { + "type": "string" + }, + "title": "Permissions", + "type": "array" + } + }, + "required": [ + "id", + "permissions" + ], + "title": "GetUserResponse", + "type": "object" + }, + "GraphImplementation": { + "description": "Represents the graph component implementation.", + "properties": { + "graph": { + "$ref": "#/components/schemas/GraphSpec" + } + }, + "required": [ + "graph" + ], + "title": "GraphImplementation", + "type": "object" + }, + "GraphInputArgument": { + "description": "Represents the component argument value that comes from the graph component input.", + "properties": { + "graphInput": { + "$ref": "#/components/schemas/GraphInputReference" + } + }, + "required": [ + "graphInput" + ], + "title": "GraphInputArgument", + "type": "object" + }, + "GraphInputReference": { + "description": "References the input of the graph (the scope is a single graph).", + "properties": { + "inputName": { + "title": "Inputname", + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Type" + } + }, + "required": [ + "inputName" + ], + "title": "GraphInputReference", + "type": "object" + }, + "GraphSpec": { + "description": "Describes the graph component implementation. It represents a graph of component tasks connected to the upstream sources of data using the argument specifications. It also describes the sources of graph output values.", + "properties": { + "outputValues": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/GraphInputArgument" + }, + { + "$ref": "#/components/schemas/TaskOutputArgument" + }, + { + "$ref": "#/components/schemas/DynamicDataArgument" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Outputvalues" + }, + "tasks": { + "additionalProperties": { + "$ref": "#/components/schemas/TaskSpec" + }, + "title": "Tasks", + "type": "object" + } + }, + "required": [ + "tasks" + ], + "title": "GraphSpec", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "IfPlaceholder": { + "description": "Represents the command-line argument placeholder that will be replaced at run-time by the expanded value of either \"then_value\" or \"else_value\" depending on the submission-time resolved value of the \"cond\" predicate.", + "properties": { + "if": { + "$ref": "#/components/schemas/IfPlaceholderStructure" + } + }, + "required": [ + "if" + ], + "title": "IfPlaceholder", + "type": "object" + }, + "IfPlaceholderStructure": { + "description": "Used in by the IfPlaceholder - the command-line argument placeholder that will be replaced at run-time by the expanded value of either \"then_value\" or \"else_value\" depending on the submission-time resolved value of the \"cond\" predicate.", + "properties": { + "cond": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "$ref": "#/components/schemas/IsPresentPlaceholder" + }, + { + "$ref": "#/components/schemas/InputValuePlaceholder" + } + ], + "title": "Cond" + }, + "else": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/InputValuePlaceholder" + }, + { + "$ref": "#/components/schemas/InputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/OutputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/ConcatPlaceholder" + }, + { + "$ref": "#/components/schemas/IfPlaceholder" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Else" + }, + "then": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/InputValuePlaceholder" + }, + { + "$ref": "#/components/schemas/InputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/OutputPathPlaceholder" + }, + { + "$ref": "#/components/schemas/ConcatPlaceholder" + }, + { + "$ref": "#/components/schemas/IfPlaceholder" + } + ] + }, + "title": "Then", + "type": "array" + } + }, + "required": [ + "cond", + "then" + ], + "title": "IfPlaceholderStructure", + "type": "object" + }, + "InputPathPlaceholder": { + "description": "Represents the command-line argument placeholder that will be replaced at run-time by a local file path pointing to a file containing the input argument value.", + "properties": { + "inputPath": { + "title": "Inputpath", + "type": "string" + } + }, + "required": [ + "inputPath" + ], + "title": "InputPathPlaceholder", + "type": "object" + }, + "InputSpec": { + "description": "Describes the component input specification", + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Default" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "optional": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Optional" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Type" + } + }, + "required": [ + "name" + ], + "title": "InputSpec", + "type": "object" + }, + "InputValuePlaceholder": { + "description": "Represents the command-line argument placeholder that will be replaced at run-time by the input argument value.", + "properties": { + "inputValue": { + "title": "Inputvalue", + "type": "string" + } + }, + "required": [ + "inputValue" + ], + "title": "InputValuePlaceholder", + "type": "object" + }, + "IsPresentPlaceholder": { + "description": "Represents the command-line argument placeholder that will be replaced at run-time by a boolean value specifying whether the caller has passed an argument for the specified optional input.", + "properties": { + "isPresent": { + "title": "Ispresent", + "type": "string" + } + }, + "required": [ + "isPresent" + ], + "title": "IsPresentPlaceholder", + "type": "object" + }, + "ListComponentLibrariesResponse": { + "properties": { + "component_libraries": { + "items": { + "$ref": "#/components/schemas/ComponentLibraryResponse" + }, + "title": "Component Libraries", + "type": "array" + } + }, + "required": [ + "component_libraries" + ], + "title": "ListComponentLibrariesResponse", + "type": "object" + }, + "ListPipelineJobsResponse": { + "properties": { + "next_page_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Page Token" + }, + "pipeline_runs": { + "items": { + "$ref": "#/components/schemas/PipelineRunResponse" + }, + "title": "Pipeline Runs", + "type": "array" + } + }, + "required": [ + "pipeline_runs" + ], + "title": "ListPipelineJobsResponse", + "type": "object" + }, + "ListPublishedComponentsResponse": { + "properties": { + "published_components": { + "items": { + "$ref": "#/components/schemas/PublishedComponentResponse" + }, + "title": "Published Components", + "type": "array" + } + }, + "required": [ + "published_components" + ], + "title": "ListPublishedComponentsResponse", + "type": "object" + }, + "ListSecretsResponse": { + "properties": { + "secrets": { + "items": { + "$ref": "#/components/schemas/SecretInfoResponse" + }, + "title": "Secrets", + "type": "array" + } + }, + "required": [ + "secrets" + ], + "title": "ListSecretsResponse", + "type": "object" + }, + "MetadataSpec": { + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "labels": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Labels" + } + }, + "title": "MetadataSpec", + "type": "object" + }, + "OutputPathPlaceholder": { + "description": "Represents the command-line argument placeholder that will be replaced at run-time by a local file path pointing to a file where the program should write its output data.", + "properties": { + "outputPath": { + "title": "Outputpath", + "type": "string" + } + }, + "required": [ + "outputPath" + ], + "title": "OutputPathPlaceholder", + "type": "object" + }, + "OutputSpec": { + "description": "Describes the component output specification", + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Type" + } + }, + "required": [ + "name" + ], + "title": "OutputSpec", + "type": "object" + }, + "PipelineRunResponse": { + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, + "execution_status_stats": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Execution Status Stats" + }, + "execution_summary": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutionStatusSummary" + }, + { + "type": "null" + } + ] + }, + "id": { + "title": "Id", + "type": "string" + }, + "pipeline_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pipeline Name" + }, + "root_execution_id": { + "title": "Root Execution Id", + "type": "string" + } + }, + "required": [ + "id", + "root_execution_id" + ], + "title": "PipelineRunResponse", + "type": "object" + }, + "PublishedComponentResponse": { + "properties": { + "deprecated": { + "default": false, + "title": "Deprecated", + "type": "boolean" + }, + "digest": { + "title": "Digest", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "published_by": { + "title": "Published By", + "type": "string" + }, + "superseded_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Superseded By" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + } + }, + "required": [ + "digest", + "published_by" + ], + "title": "PublishedComponentResponse", + "type": "object" + }, + "RetryStrategySpec": { + "properties": { + "maxRetries": { + "title": "Maxretries", + "type": "integer" + } + }, + "required": [ + "maxRetries" + ], + "title": "RetryStrategySpec", + "type": "object" + }, + "SecretInfoResponse": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "secret_name": { + "title": "Secret Name", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "secret_name", + "created_at", + "updated_at" + ], + "title": "SecretInfoResponse", + "type": "object" + }, + "TaskOutputArgument": { + "description": "Represents the component argument value that comes from the output of another task.", + "properties": { + "taskOutput": { + "$ref": "#/components/schemas/TaskOutputReference" + } + }, + "required": [ + "taskOutput" + ], + "title": "TaskOutputArgument", + "type": "object" + }, + "TaskOutputReference": { + "description": "References the output of some task (the scope is a single graph).", + "properties": { + "outputName": { + "title": "Outputname", + "type": "string" + }, + "taskId": { + "title": "Taskid", + "type": "string" + } + }, + "required": [ + "outputName", + "taskId" + ], + "title": "TaskOutputReference", + "type": "object" + }, + "TaskSpec": { + "description": "Task specification. Task is a \"configured\" component - a component supplied with arguments and other applied configuration changes.", + "properties": { + "annotations": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/GraphInputArgument" + }, + { + "$ref": "#/components/schemas/TaskOutputArgument" + }, + { + "$ref": "#/components/schemas/DynamicDataArgument" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Arguments" + }, + "componentRef": { + "$ref": "#/components/schemas/ComponentReference" + }, + "executionOptions": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutionOptionsSpec" + }, + { + "type": "null" + } + ] + }, + "isEnabled": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/GraphInputArgument" + }, + { + "$ref": "#/components/schemas/TaskOutputArgument" + }, + { + "$ref": "#/components/schemas/DynamicDataArgument" + }, + { + "type": "null" + } + ], + "title": "Isenabled" + } + }, + "required": [ + "componentRef" + ], + "title": "TaskSpec", + "type": "object" + }, + "UserComponentLibraryPinsResponse": { + "properties": { + "component_library_ids": { + "items": { + "type": "string" + }, + "title": "Component Library Ids", + "type": "array" + } + }, + "required": [ + "component_library_ids" + ], + "title": "UserComponentLibraryPinsResponse", + "type": "object" + }, + "UserSettingsResponse": { + "properties": { + "settings": { + "additionalProperties": true, + "title": "Settings", + "type": "object" + } + }, + "required": [ + "settings" + ], + "title": "UserSettingsResponse", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "Cloud Pipelines API", + "version": "0.0.1" + }, + "openapi": "3.1.0", + "paths": { + "/api/admin/execution_node/{id}/status": { + "put": { + "operationId": "admin_set_execution_node_status_api_admin_execution_node__id__status_put", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "query", + "name": "status", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContainerExecutionStatus" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Set Execution Node Status", + "tags": [ + "admin" + ] + } + }, + "/api/admin/set_read_only_model": { + "put": { + "operationId": "admin_set_read_only_model_api_admin_set_read_only_model_put", + "parameters": [ + { + "in": "query", + "name": "read_only", + "required": true, + "schema": { + "title": "Read Only", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Set Read Only Model", + "tags": [ + "admin" + ] + } + }, + "/api/admin/sql_engine_connection_pool_status": { + "get": { + "operationId": "get_sql_engine_connection_pool_status_api_admin_sql_engine_connection_pool_status_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Sql Engine Connection Pool Status Api Admin Sql Engine Connection Pool Status Get", + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Sql Engine Connection Pool Status" + } + }, + "/api/artifacts/{id}": { + "get": { + "operationId": "get_api_artifacts__id__get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetArtifactInfoResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get", + "tags": [ + "artifacts" + ] + } + }, + "/api/artifacts/{id}/signed_artifact_url": { + "get": { + "operationId": "get_signed_artifact_url_api_artifacts__id__signed_artifact_url_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetArtifactSignedUrlResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Signed Artifact Url", + "tags": [ + "artifacts" + ] + } + }, + "/api/component_libraries/": { + "get": { + "operationId": "list_api_component_libraries__get", + "parameters": [ + { + "in": "query", + "name": "name_substring", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name Substring" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListComponentLibrariesResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List", + "tags": [ + "components" + ] + }, + "post": { + "operationId": "create_api_component_libraries__post", + "parameters": [ + { + "in": "query", + "name": "hide_from_search", + "required": false, + "schema": { + "default": false, + "title": "Hide From Search", + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentLibrary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentLibraryResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create", + "tags": [ + "components" + ] + } + }, + "/api/component_libraries/{id}": { + "get": { + "operationId": "get_api_component_libraries__id__get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "query", + "name": "include_component_texts", + "required": false, + "schema": { + "default": false, + "title": "Include Component Texts", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentLibraryResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get", + "tags": [ + "components" + ] + }, + "put": { + "operationId": "replace_api_component_libraries__id__put", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "query", + "name": "hide_from_search", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Hide From Search" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentLibrary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentLibraryResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Replace", + "tags": [ + "components" + ] + } + }, + "/api/component_library_pins/me/": { + "get": { + "operationId": "get_component_library_pins_api_component_library_pins_me__get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserComponentLibraryPinsResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Component Library Pins", + "tags": [ + "components" + ] + }, + "put": { + "operationId": "set_component_library_pins_api_component_library_pins_me__put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "title": "Component Library Ids", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Set Component Library Pins", + "tags": [ + "components" + ] + } + }, + "/api/components/{digest}": { + "get": { + "operationId": "get_api_components__digest__get", + "parameters": [ + { + "in": "path", + "name": "digest", + "required": true, + "schema": { + "title": "Digest", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get", + "tags": [ + "components" + ] + } + }, + "/api/executions/{id}/artifacts": { + "get": { + "operationId": "get_artifacts_api_executions__id__artifacts_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetExecutionArtifactsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Artifacts", + "tags": [ + "executions" + ] + } + }, + "/api/executions/{id}/container_log": { + "get": { + "operationId": "get_container_log_api_executions__id__container_log_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetContainerExecutionLogResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Container Log", + "tags": [ + "executions" + ] + } + }, + "/api/executions/{id}/container_state": { + "get": { + "operationId": "get_container_execution_state_api_executions__id__container_state_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "query", + "name": "include_execution_nodes_linked_to_same_container_execution", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Include Execution Nodes Linked To Same Container Execution" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetContainerExecutionStateResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Container Execution State", + "tags": [ + "executions" + ] + } + }, + "/api/executions/{id}/details": { + "get": { + "operationId": "get_api_executions__id__details_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetExecutionInfoResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get", + "tags": [ + "executions" + ] + } + }, + "/api/executions/{id}/graph_execution_state": { + "get": { + "operationId": "get_graph_execution_state_api_executions__id__graph_execution_state_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetGraphExecutionStateResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Graph Execution State", + "tags": [ + "executions" + ] + } + }, + "/api/executions/{id}/state": { + "get": { + "operationId": "get_graph_execution_state_api_executions__id__state_get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetGraphExecutionStateResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Graph Execution State", + "tags": [ + "executions" + ] + } + }, + "/api/pipeline_runs/": { + "get": { + "operationId": "list_api_pipeline_runs__get", + "parameters": [ + { + "in": "query", + "name": "page_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Page Token" + } + }, + { + "in": "query", + "name": "filter", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter" + } + }, + { + "in": "query", + "name": "filter_query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Query" + } + }, + { + "in": "query", + "name": "include_pipeline_names", + "required": false, + "schema": { + "default": false, + "title": "Include Pipeline Names", + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_execution_stats", + "required": false, + "schema": { + "default": false, + "title": "Include Execution Stats", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPipelineJobsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List", + "tags": [ + "pipelineRuns" + ] + }, + "post": { + "operationId": "create_api_pipeline_runs__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_create_api_pipeline_runs__post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineRunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create", + "tags": [ + "pipelineRuns" + ] + } + }, + "/api/pipeline_runs/{id}": { + "get": { + "operationId": "get_api_pipeline_runs__id__get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "query", + "name": "include_execution_stats", + "required": false, + "schema": { + "default": false, + "title": "Include Execution Stats", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineRunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get", + "tags": [ + "pipelineRuns" + ] + } + }, + "/api/pipeline_runs/{id}/annotations/": { + "get": { + "operationId": "list_annotations_api_pipeline_runs__id__annotations__get", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response List Annotations Api Pipeline Runs Id Annotations Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Annotations", + "tags": [ + "pipelineRuns" + ] + } + }, + "/api/pipeline_runs/{id}/annotations/{key}": { + "delete": { + "operationId": "delete_annotation_api_pipeline_runs__id__annotations__key__delete", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "title": "Key", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Annotation", + "tags": [ + "pipelineRuns" + ] + }, + "put": { + "operationId": "set_annotation_api_pipeline_runs__id__annotations__key__put", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "title": "Key", + "type": "string" + } + }, + { + "in": "query", + "name": "value", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Set Annotation", + "tags": [ + "pipelineRuns" + ] + } + }, + "/api/pipeline_runs/{id}/cancel": { + "post": { + "operationId": "pipeline_run_cancel_api_pipeline_runs__id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Pipeline Run Cancel", + "tags": [ + "pipelineRuns" + ] + } + }, + "/api/published_components/": { + "get": { + "operationId": "list_api_published_components__get", + "parameters": [ + { + "in": "query", + "name": "include_deprecated", + "required": false, + "schema": { + "default": false, + "title": "Include Deprecated", + "type": "boolean" + } + }, + { + "in": "query", + "name": "name_substring", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name Substring" + } + }, + { + "in": "query", + "name": "published_by_substring", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Published By Substring" + } + }, + { + "in": "query", + "name": "digest", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Digest" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPublishedComponentsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List", + "tags": [ + "components" + ] + }, + "post": { + "operationId": "publish_api_published_components__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentReference" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishedComponentResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Publish", + "tags": [ + "components" + ] + } + }, + "/api/published_components/{digest}": { + "put": { + "operationId": "update_api_published_components__digest__put", + "parameters": [ + { + "in": "path", + "name": "digest", + "required": true, + "schema": { + "title": "Digest", + "type": "string" + } + }, + { + "in": "query", + "name": "deprecated", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Deprecated" + } + }, + { + "in": "query", + "name": "superseded_by", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Superseded By" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishedComponentResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update", + "tags": [ + "components" + ] + } + }, + "/api/secrets/": { + "get": { + "operationId": "list_secrets_api_secrets__get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSecretsResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Secrets", + "tags": [ + "secrets" + ] + }, + "post": { + "operationId": "create_secret_api_secrets__post", + "parameters": [ + { + "in": "query", + "name": "secret_name", + "required": true, + "schema": { + "title": "Secret Name", + "type": "string" + } + }, + { + "in": "query", + "name": "description", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + { + "in": "query", + "name": "expires_at", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_create_secret_api_secrets__post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretInfoResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Secret", + "tags": [ + "secrets" + ] + } + }, + "/api/secrets/{secret_name}": { + "delete": { + "operationId": "delete_secret_api_secrets__secret_name__delete", + "parameters": [ + { + "in": "path", + "name": "secret_name", + "required": true, + "schema": { + "title": "Secret Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Secret", + "tags": [ + "secrets" + ] + }, + "put": { + "operationId": "update_secret_api_secrets__secret_name__put", + "parameters": [ + { + "in": "path", + "name": "secret_name", + "required": true, + "schema": { + "title": "Secret Name", + "type": "string" + } + }, + { + "in": "query", + "name": "description", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + { + "in": "query", + "name": "expires_at", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_update_secret_api_secrets__secret_name__put" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretInfoResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Secret", + "tags": [ + "secrets" + ] + } + }, + "/api/users/me": { + "get": { + "operationId": "get_current_user_api_users_me_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/GetUserResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Current User Api Users Me Get" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Current User", + "tags": [ + "users" + ] + } + }, + "/api/users/me/settings": { + "delete": { + "operationId": "delete_settings_api_users_me_settings_delete", + "parameters": [ + { + "in": "query", + "name": "setting_names", + "required": true, + "schema": { + "items": { + "type": "string" + }, + "title": "Setting Names", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Settings", + "tags": [ + "user_settings" + ] + }, + "get": { + "description": "Gets user settings.\n\nIf `setting_names` is specified, returns only those settings.", + "operationId": "get_settings_api_users_me_settings_get", + "parameters": [ + { + "in": "query", + "name": "setting_names", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Setting Names" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSettingsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Settings", + "tags": [ + "user_settings" + ] + }, + "patch": { + "operationId": "set_settings_api_users_me_settings_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_set_settings_api_users_me_settings_patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Set Settings", + "tags": [ + "user_settings" + ] + } + } + } +} diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py new file mode 100644 index 0000000..fa83c8d --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -0,0 +1,19 @@ +"""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. +""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as metadata_version + +from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient + +try: + __version__ = metadata_version("tangle-cli") +except PackageNotFoundError: + __version__ = "0.1.0" + +__all__ = ["TangleDynamicDiscoveryClient", "__version__"] diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py new file mode 100644 index 0000000..3a496f7 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -0,0 +1,787 @@ +"""OpenAPI-backed `tangle api` command implementation. + +The backend exposes a FastAPI OpenAPI schema. Schema cache, operation naming, +parameter mapping, and HTTP dispatch live in reusable modules so the CLI and +programmatic client share one behavior. Static commands are registered from the +checked-in OpenAPI snapshot, while `refresh` can update the dynamic schema cache +for expansion against a live backend. Commands are generated only when the root +CLI is being built for an actual `tangle api ...` invocation, so importing this +module never reads ambient argv, touches the schema cache, or contacts the +backend. +""" + +from __future__ import annotations + +import inspect +import json +import os +import re +import sys +from typing import Annotated, Any + +import httpx +import platformdirs +from cyclopts import App, Parameter + +from .args_container import ArgsContainer, ConfigFileError +from .api_schema import ( + SUPPORTED_METHODS, + CliParameter, + OperationCommand, + cache_path, + default_cache_dir, + fetch_schema, + load_cached_schema, + load_or_fetch_schema, + operation_commands, + refresh_schema, + write_cached_schema, + _dedupe_command_name, + _flatten_schema, + _is_path_param, + _is_simple_schema, + _iter_operation_commands, + _json_request_body_schema, + _method_sort_key, + _normalize_name, + _operation_command_name, + _operation_group_name, + _operation_parameters, + _path_parts, + _request_body_parameters, + _resolve_ref, + _safe_identifier, + _same_operation, + _schema_to_python_type, + _unwrap_nullable_schema, +) +from .api_transport import ( + DEFAULT_TIMEOUT_SECONDS, + _ambient_auth_env_present, + _env_header_entries, + _headers_from_env, + _load_body_argument, + _normalize_auth_header, + _normalize_base_url, + _openapi_url, + _parse_header_entries, + _request_headers, + _urlencode_query, + default_auth_header, + default_base_url, + default_token, + request_operation, +) +from .cli_helpers import api_arg_specs, load_args_or_exit +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + TokenOption, +) +from .openapi.parser import load_openapi_schema as load_bundled_openapi_schema + +BodyOption = Annotated[ + str | None, + Parameter(help="JSON request body, or @path/to/file.json."), +] +SchemaSourceOption = Annotated[ + str, + Parameter( + help=( + "OpenAPI schema source for generated API commands: 'auto' merges " + "checked-in official operations with cached backend extensions " + "(default); 'official' uses only the checked-in static schema; " + "'cache' uses only a schema previously written by `tangle api refresh`." + ) + ), +] + + +def build_app(schema: dict[str, Any] | None = None) -> App: + """Build the `tangle api` Cyclopts app. + + When *schema* is supplied, commands are generated from it. Otherwise the + checked-in official OpenAPI snapshot is always used, and cached live backend + operations are merged in as dynamic extensions by default. Official + definitions win for matching method/path operations. + """ + + api_app = App( + name="api", + help="Call Tangle backend API endpoints from the checked-in OpenAPI schema.", + ) + _register_refresh_command(api_app) + _register_reset_cache_command(api_app) + _register_schema_source_option(api_app) + + schema = schema if schema is not None else _schema_for_current_invocation() + if schema is not None: + register_dynamic_commands(api_app, schema) + + return api_app + + +def register_dynamic_commands(api_app: App, schema: dict[str, Any]) -> None: + """Attach generated resource groups and endpoint commands to `api_app`.""" + + groups: dict[str, App] = {} + + for operation in operation_commands(schema): + group = groups.get(operation.group_name) + if group is None: + group = App( + name=operation.group_name, + help=f"Call {operation.group_name} API endpoints.", + ) + _register_schema_source_option(group) + groups[operation.group_name] = group + api_app.command(group) + + command = _make_operation_callable(operation) + group.command(command, name=operation.command_name) + + +def _register_schema_source_option(app: App) -> None: + @app.default + def schema_source_option(*, schema_source: SchemaSourceOption = "auto") -> None: + """Select merged, official-only, or raw cached backend schema.""" + + _validate_schema_source(schema_source) + + +def _register_refresh_command(api_app: App) -> None: + @api_app.command(name="refresh") + def refresh( + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + ) -> None: + """Fetch /openapi.json and update the local schema cache.""" + + for args in load_args_or_exit( + config, + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + base_url_from_config = base_url is None and "base_url" in args._config + normalized_base_url = ( + _normalize_base_url(args.base_url) if args.base_url else default_base_url() + ) + try: + schema, path = refresh_schema( + normalized_base_url, + args.token, + args.header, + args.auth_header, + include_env_credentials=not base_url_from_config, + ) + except httpx.HTTPStatusError as exc: + message = f"HTTP {exc.response.status_code} {exc.response.reason_phrase}" + raise SystemExit( + f"Failed to fetch {_openapi_url(normalized_base_url)}: {message}" + ) from exc + except httpx.RequestError as exc: + raise SystemExit( + f"Failed to fetch {_openapi_url(normalized_base_url)}: {exc}" + ) from exc + path_count = len(schema.get("paths", {})) + print(f"Cached OpenAPI schema for {normalized_base_url}") + print(f"Path: {path}") + print(f"OpenAPI paths: {path_count}") + + +def _register_reset_cache_command(api_app: App) -> None: + @api_app.command(name="reset-cache") + def reset_cache(*, base_url: BaseUrlOption = None, config: ConfigOption = None) -> None: + """Delete the cached live OpenAPI schema for a base URL.""" + + for args in load_args_or_exit(config, base_url=(base_url, None)): + normalized_base_url = ( + _normalize_base_url(args.base_url) if args.base_url else default_base_url() + ) + path = cache_path(normalized_base_url) + if path.exists(): + path.unlink() + print(f"Deleted cached OpenAPI schema for {normalized_base_url}") + print(f"Path: {path}") + else: + print(f"No cached OpenAPI schema for {normalized_base_url}") + print(f"Path: {path}") + + +def _make_operation_callable(operation: OperationCommand): + """Create the Python callable Cyclopts registers for one endpoint. + + Cyclopts introspects function metadata, so we attach a generated signature + and docstring below. The real function accepts flexible args/kwargs and + forwards normalized values to the HTTP dispatcher. + """ + + positional_names = [ + parameter.local_name + for parameter in operation.parameters + if parameter.location == "path" + ] + + def command(*args: Any, **values: Any) -> None: + for name, value in zip(positional_names, args): + values[name] = value + _invoke_operation(operation, values) + + command.__name__ = _safe_function_name(f"{operation.group_name}_{operation.command_name}") + command.__doc__ = _operation_help(operation) + command.__signature__ = _operation_signature(operation) # type: ignore[attr-defined] + return command + + +def _operation_signature(operation: OperationCommand) -> inspect.Signature: + """Build the signature Cyclopts uses for parsing and help output. + + Path parameters are positional. Query parameters and simple body fields are + keyword-only options. `--body`, `--header`, `--auth-header`, `--base-url`, + and `--token` are appended as common generated-command options. + """ + + parameters: list[inspect.Parameter] = [] + + for parameter in operation.parameters: + if parameter.location != "path": + continue + annotation = _annotated_type(parameter.python_type, parameter.description) + parameters.append( + inspect.Parameter( + parameter.local_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default=None, + annotation=_optional_type(annotation), + ) + ) + + for parameter in operation.parameters: + if parameter.location not in {"query", "body"}: + continue + body_field_with_escape_hatch = parameter.location == "body" and operation.has_request_body + annotation = _annotated_type( + _optional_type(parameter.python_type) + if not parameter.required or body_field_with_escape_hatch + else parameter.python_type, + parameter.description, + ) + default = parameter.default if not parameter.required else None + parameters.append( + inspect.Parameter( + parameter.local_name, + inspect.Parameter.KEYWORD_ONLY, + default=default, + annotation=annotation, + ) + ) + + if operation.has_request_body: + parameters.append( + inspect.Parameter( + "body", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=BodyOption, + ) + ) + + parameters.append( + inspect.Parameter( + "config", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=ConfigOption, + ) + ) + parameters.append( + inspect.Parameter( + "schema_source", + inspect.Parameter.KEYWORD_ONLY, + default="auto", + annotation=SchemaSourceOption, + ) + ) + parameters.append( + inspect.Parameter( + "auth_header", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=AuthHeaderOption, + ) + ) + parameters.append( + inspect.Parameter( + "header", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=HeaderOption, + ) + ) + + parameters.extend( + [ + inspect.Parameter( + "base_url", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=BaseUrlOption, + ), + inspect.Parameter( + "token", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=TokenOption, + ), + ] + ) + return inspect.Signature(parameters=parameters) + + +def _optional_type(python_type: Any) -> Any: + return python_type | None + + +def _annotated_type(python_type: Any, description: str) -> Any: + if description: + return Annotated[python_type, Parameter(help=description)] + return python_type + + +def _operation_help(operation: OperationCommand) -> str: + summary = operation.operation.get("summary") or operation.operation.get("description") + if summary: + return str(summary).strip() + return f"{operation.method} {operation.path}" + + +def _invoke_operation(operation: OperationCommand, values: dict[str, Any]) -> None: + """Turn parsed CLI values into an HTTP request and print the response.""" + + config = values.pop("config", None) + cli_body = values.get("body") if operation.has_request_body else None + cli_base_url = values.get("base_url") + for args in _operation_args_from_config(operation, values, config): + body_from_config = operation.has_request_body and cli_body is None and "body" in args._config + base_url_from_config = cli_base_url is None and "base_url" in args._config + _invoke_operation_once( + operation, + args.to_dict(), + allow_body_file_references=not body_from_config, + include_env_credentials=not base_url_from_config, + ) + + +def _operation_args_from_config( + operation: OperationCommand, + values: dict[str, Any], + config: str | None, +) -> list[ArgsContainer]: + specs: dict[str, tuple[Any, ...]] = {} + for parameter in operation.parameters: + default = parameter.default if not parameter.required else None + required = parameter.required and parameter.location != "body" + specs[parameter.local_name] = ( + parameter.local_name, + values.get(parameter.local_name, default), + default, + False, + required, + ) + + specs["schema_source"] = (values.get("schema_source", "auto"), "auto") + if operation.has_request_body: + specs["body"] = (values.get("body"), None) + specs.update( + api_arg_specs( + base_url=values.get("base_url"), + token=values.get("token"), + auth_header=values.get("auth_header"), + header=values.get("header"), + ) + ) + resolved = load_args_or_exit(config, **specs) + for args in resolved: + for parameter in operation.parameters: + if parameter.required or parameter.default is None: + continue + if parameter.local_name in args._config: + continue + if getattr(args, parameter.local_name, None) == parameter.default: + setattr(args, parameter.local_name, None) + return resolved + + +def _invoke_operation_once( + operation: OperationCommand, + values: dict[str, Any], + *, + allow_body_file_references: bool = True, + include_env_credentials: bool = True, +) -> None: + _validate_schema_source(values.pop("schema_source", "official")) + base_url = _normalize_base_url(values.pop("base_url", None) or default_base_url()) + token = values.pop("token", None) + if token is None and include_env_credentials: + token = default_token() + auth_header = values.pop("auth_header", None) + header_entries = values.pop("header", None) + body_arg = values.pop("body", None) if operation.has_request_body else None + + try: + response = request_operation( + operation, + values, + base_url=base_url, + token=token, + auth_header=auth_header, + header_entries=header_entries, + body=body_arg, + timeout=DEFAULT_TIMEOUT_SECONDS, + allow_body_file_references=allow_body_file_references, + include_env_credentials=include_env_credentials, + ) + except httpx.HTTPStatusError as exc: + message = exc.response.text or exc.response.reason_phrase + print(message, file=sys.stderr) + raise SystemExit(exc.response.status_code) from exc + except httpx.RequestError as exc: + raise SystemExit(f"Failed to call {exc.request.url}: {exc}") from exc + except TypeError as exc: + raise SystemExit(str(exc)) from exc + + if not response.content: + return + text = response.text + if "json" in response.headers.get("Content-Type", "").lower(): + try: + print(json.dumps(json.loads(text), indent=2, sort_keys=True)) + return + except json.JSONDecodeError: + pass + print(text) + + +def _schema_for_current_invocation() -> dict[str, Any] | None: + """Return schema needed to build API commands for this process. + + Static commands come from the checked-in official OpenAPI snapshot and are + available on a cold cache. By default, cached live backend operations are + merged in as extensions without overriding official operations. + """ + + api_tail = _api_argv_tail(sys.argv) + if api_tail is None: + return None + + first_command = _api_first_command(api_tail) + if first_command in {"refresh", "reset-cache"}: + return None + help_requested = _api_tail_requests_help(api_tail) + + schema_source = _schema_source_from_argv(api_tail) + base_url_arg, base_url_source = _base_url_with_source_from_argv(api_tail) + configured_base_url = base_url_arg or os.environ.get("TANGLE_API_URL") + include_env_credentials = base_url_source != "config" + token = _token_from_argv(api_tail, include_env_credentials=include_env_credentials) + auth_header = _auth_header_from_argv(api_tail, include_env_credentials=include_env_credentials) + header = _headers_from_argv(api_tail) + if schema_source == "cache": + base_url = configured_base_url or default_base_url() + cached = load_cached_schema(base_url) + if cached is 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." + ) + return cached + + cache_base_url = _auto_cache_base_url(configured_base_url, help_requested) + cached = load_cached_schema(cache_base_url) if cache_base_url else None + try: + official = load_bundled_openapi_schema() + except FileNotFoundError as exc: + if first_command is None: + return None + if schema_source == "auto" and cache_base_url: + try: + return load_or_fetch_schema( + cache_base_url, + token=token, + header=header, + auth_header=auth_header, + include_env_credentials=include_env_credentials, + ) + except (httpx.HTTPError, RuntimeError, ValueError, json.JSONDecodeError) as fetch_exc: + raise SystemExit(_missing_official_schema_message()) from fetch_exc + raise SystemExit(_missing_official_schema_message()) from exc + + if schema_source == "official": + return official + if cached is None: + return official + return _merge_official_with_cached_extensions(official, cached) + + +def _auto_cache_base_url( + configured_base_url: str | None, + help_requested: bool, +) -> str | None: + if configured_base_url: + return configured_base_url + if help_requested and _ambient_auth_env_present() and not os.environ.get("TANGLE_API_URL"): + return None + return default_base_url() + + +def _api_tail_requests_help(api_tail: list[str]) -> bool: + skip_next = False + options_with_values = { + "--base-url", + "--api-url", + "--token", + "--auth-header", + "--header", + "-H", + "--schema-source", + "--config", + } + for arg in api_tail: + if skip_next: + skip_next = False + continue + if arg in options_with_values: + skip_next = True + continue + if arg in {"--help", "-h"}: + return True + return False + + +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 " + "`--schema-source cache` for cached backend operations." + ) + + +def _merge_official_with_cached_extensions( + official: dict[str, Any], + cached: dict[str, Any], +) -> dict[str, Any]: + """Return official schema plus cached-only extension operations. + + Official operations win for matching method/path pairs. Cached schemas can + contribute entirely new paths, additional methods on existing paths, and + component definitions needed by cached-only extension operations. + """ + + merged = json.loads(json.dumps(official)) + cached_paths = cached.get("paths", {}) or {} + merged_paths = merged.setdefault("paths", {}) + for path, cached_path_item in cached_paths.items(): + if not isinstance(cached_path_item, dict): + continue + if path not in merged_paths or not isinstance(merged_paths[path], dict): + merged_paths[path] = json.loads(json.dumps(cached_path_item)) + continue + merged_path_item = merged_paths[path] + for key, value in cached_path_item.items(): + if key.lower() in SUPPORTED_METHODS: + # Preserve official operation definitions when method/path match. + merged_path_item.setdefault(key, json.loads(json.dumps(value))) + elif key not in merged_path_item: + # Preserve cached-only path-level metadata for cached-only methods. + merged_path_item[key] = json.loads(json.dumps(value)) + + _merge_missing_dict_keys(merged.setdefault("components", {}), cached.get("components", {}) or {}) + return merged + + +def _merge_missing_dict_keys(target: dict[str, Any], source: dict[str, Any]) -> None: + for key, value in source.items(): + if key not in target: + target[key] = json.loads(json.dumps(value)) + elif isinstance(target[key], dict) and isinstance(value, dict): + _merge_missing_dict_keys(target[key], value) + + +def _argv_requests_api_schema(argv: list[str]) -> bool: + api_tail = _api_argv_tail(argv) + if api_tail is None: + return False + first_command = _api_first_command(api_tail) + return first_command not in {None, "refresh", "reset-cache"} + + +def _argv_dispatches_dynamic_command(argv: list[str]) -> bool: + api_tail = _api_argv_tail(argv) + if api_tail is None: + return False + first_command = _api_first_command(api_tail) + return first_command not in {None, "refresh", "reset-cache"} + + +def _api_argv_tail(argv: list[str]) -> list[str] | None: + """Return args after the root `api` command, or None for non-API invocations.""" + + args = list(argv[1:]) + for index, arg in enumerate(args): + if arg == "--": + if index + 1 < len(args) and args[index + 1] == "api": + return args[index + 2 :] + return None + if arg in {"--help", "-h", "--version"}: + return None + if arg.startswith("-"): + return None + return args[index + 1 :] if arg == "api" else None + return None + + +def _api_first_command(api_tail: list[str]) -> str | None: + skip_next = False + options_with_values = { + "--base-url", + "--api-url", + "--token", + "--auth-header", + "--header", + "-H", + "--schema-source", + "--config", + } + for arg in api_tail: + if skip_next: + skip_next = False + continue + if arg in options_with_values: + skip_next = True + continue + if arg in {"--help", "-h"}: + return None + if arg.startswith("--"): + continue + return arg + return None + + +def _schema_source_from_argv(argv: list[str]) -> str: + value = _option_from_argv(argv, "--schema-source") + if value is None: + value = _config_value_from_argv(argv, "schema_source") + return _validate_schema_source(str(value or "auto")) + + +def _validate_schema_source(value: str) -> str: + normalized = value.strip().lower() + if normalized not in {"auto", "official", "cache"}: + raise SystemExit("--schema-source must be 'auto', 'official', or 'cache'") + return normalized + + +def _schema_fetch_failure_message(base_url: str, exc: Exception) -> str: + if isinstance(exc, httpx.HTTPStatusError): + reason = f"HTTP {exc.response.status_code} {exc.response.reason_phrase}" + elif isinstance(exc, httpx.RequestError): + reason = str(exc) + else: + reason = exc.__class__.__name__ + return ( + f"No cached OpenAPI schema for {_normalize_base_url(base_url)}, and fetching " + f"{_openapi_url(base_url)} failed: {reason}. Run `tangle api refresh` " + "with the same --base-url/--auth-header/--header options, or set " + "TANGLE_API_URL/TANGLE_API_AUTH_HEADER/TANGLE_API_HEADERS." + ) + + +def _base_url_from_argv(argv: list[str]) -> str | None: + value, _source = _base_url_with_source_from_argv(argv) + return value + + +def _base_url_with_source_from_argv(argv: list[str]) -> tuple[str | None, str | None]: + cli_value = _option_from_argv(argv, "--base-url") or _option_from_argv(argv, "--api-url") + if cli_value is not None: + return cli_value, "cli" + config_value = _optional_str(_config_value_from_argv(argv, "base_url")) + if config_value is not None: + return config_value, "config" + return None, None + + +def _token_from_argv(argv: list[str], *, include_env_credentials: bool = True) -> str | None: + token = _option_from_argv(argv, "--token") or _optional_str(_config_value_from_argv(argv, "token")) + if token is None and include_env_credentials: + token = default_token() + return token + + +def _auth_header_from_argv(argv: list[str], *, include_env_credentials: bool = True) -> str | None: + auth_header = _option_from_argv(argv, "--auth-header") or _optional_str( + _config_value_from_argv(argv, "auth_header") + ) + if auth_header is None and include_env_credentials: + auth_header = default_auth_header() + return auth_header + + +def _config_value_from_argv(argv: list[str], key: str) -> Any: + config_path = _option_from_argv(argv, "--config") + if config_path is None: + return None + try: + configs = ArgsContainer._load_config_file(config_path) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + if not configs: + return None + return configs[0].get(key) + + +def _optional_str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _headers_from_argv(argv: list[str]) -> list[str]: + entries: list[str] = [] + for index, arg in enumerate(argv): + if arg in {"--header", "-H"} and index + 1 < len(argv): + entries.append(argv[index + 1]) + elif arg.startswith("--header="): + entries.append(arg.split("=", 1)[1]) + if entries: + return entries + + config_header = _config_value_from_argv(argv, "header") + if isinstance(config_header, list): + return [str(entry) for entry in config_header] + if isinstance(config_header, str): + return [config_header] + return [] + + +def _option_from_argv(argv: list[str], option: str) -> str | None: + for index, arg in enumerate(argv): + if arg == option and index + 1 < len(argv): + return argv[index + 1] + if arg.startswith(option + "="): + return arg.split("=", 1)[1] + return None + + +def _safe_function_name(name: str) -> str: + return re.sub(r"\W+", "_", name).strip("_") or "api_command" diff --git a/packages/tangle-cli/src/tangle_cli/api_schema.py b/packages/tangle-cli/src/tangle_cli/api_schema.py new file mode 100644 index 0000000..5475ea8 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/api_schema.py @@ -0,0 +1,633 @@ +"""OpenAPI schema cache and operation mapping utilities for Tangle APIs.""" + +from __future__ import annotations + +import hashlib +import json +import keyword +import re +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Literal + +import httpx +import platformdirs + +from .api_transport import ( + DEFAULT_TIMEOUT_SECONDS, + _normalize_base_url, + _openapi_url, + _request_headers, + default_base_url, +) + +SUPPORTED_METHODS = {"get", "post", "put", "patch", "delete"} +_HTTP_METHOD_NAMES = { + "get": "get", + "post": "create", + "put": "update", + "patch": "update", + "delete": "delete", +} +_METHOD_PRIORITY = { + "get": 0, + "post": 1, + "put": 2, + "patch": 3, + "delete": 4, +} + + +@dataclass(frozen=True) +class CliParameter: + """Normalized OpenAPI parameter/body field for CLI and client dispatch.""" + + original_name: str + local_name: str + location: Literal["path", "query", "body"] + python_type: Any + required: bool = False + default: Any = None + description: str = "" + + +@dataclass(frozen=True) +class OperationCommand: + """Normalized OpenAPI operation ready for CLI or programmatic dispatch.""" + + group_name: str + command_name: str + method: str + path: str + operation: dict[str, Any] + parameters: tuple[CliParameter, ...] + has_request_body: bool + + @property + def operation_name(self) -> str: + return f"{self.group_name}.{self.command_name}" + + +def default_cache_dir() -> Path: + """Return the OpenAPI schema cache directory. + + ``TANGLE_CLI_CACHE_DIR`` is an explicit cache directory override for tests + and automation. Otherwise platformdirs selects the OS-appropriate user + cache directory and OpenAPI files live in an ``openapi`` subdirectory. + """ + + import os + + configured = os.environ.get("TANGLE_CLI_CACHE_DIR") + if configured: + return Path(configured).expanduser() + return Path(platformdirs.user_cache_dir("tangle-cli", "TangleML")) / "openapi" + + +def cache_path(base_url: str | None = None) -> Path: + """Return the schema cache file for a base URL.""" + + normalized = _normalize_base_url(base_url or default_base_url()) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] + return default_cache_dir() / f"schema-{digest}.json" + + +def load_cached_schema(base_url: str | None = None) -> dict[str, Any] | None: + """Load a previously fetched schema without touching the network.""" + + path = cache_path(base_url) + if not path.exists(): + return None + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def write_cached_schema(schema: dict[str, Any], base_url: str | None = None) -> Path: + """Atomically write a schema cache file and return its path.""" + + path = cache_path(base_url) + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(schema, f, indent=2, sort_keys=True) + f.write("\n") + tmp_path.replace(path) + return path + + +def fetch_schema( + base_url: str | None = None, + token: str | None = None, + header: list[str] | str | None = None, + auth_header: str | None = None, + headers: dict[str, str] | None = None, + include_env_credentials: bool = True, +) -> dict[str, Any]: + """Fetch ``/openapi.json``, applying bearer and custom auth headers.""" + + base_url = _normalize_base_url(base_url or default_base_url()) + response = httpx.get( + _openapi_url(base_url), + headers=_request_headers( + token, + header, + auth_header, + headers, + include_env_credentials=include_env_credentials, + ), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.text + schema = json.loads(payload) + if not isinstance(schema, dict) or "paths" not in schema: + raise RuntimeError("OpenAPI response did not contain a paths object") + return schema + + +def refresh_schema( + base_url: str | None = None, + token: str | None = None, + header: list[str] | str | None = None, + auth_header: str | None = None, + headers: dict[str, str] | None = None, + include_env_credentials: bool = True, +) -> tuple[dict[str, Any], Path]: + """Fetch and cache the latest schema for a backend.""" + + base_url = _normalize_base_url(base_url or default_base_url()) + schema = fetch_schema( + base_url, + token, + header, + auth_header, + headers, + include_env_credentials=include_env_credentials, + ) + path = write_cached_schema(schema, base_url) + return schema, path + + +def load_or_fetch_schema( + base_url: str | None = None, + token: str | None = None, + header: list[str] | str | None = None, + auth_header: str | None = None, + headers: dict[str, str] | None = None, + include_env_credentials: bool = True, +) -> dict[str, Any]: + """Use a cached schema when available, otherwise fetch once and cache it.""" + + cached = load_cached_schema(base_url) + if cached is not None: + return cached + schema, _ = refresh_schema( + base_url, + token, + header, + auth_header, + headers, + include_env_credentials=include_env_credentials, + ) + return schema + + +def operation_commands(schema: dict[str, Any]) -> list[OperationCommand]: + """Return normalized operations with deterministic collision handling applied.""" + + operations: list[OperationCommand] = [] + used_names: dict[str, dict[str, OperationCommand]] = {} + for operation in _iter_operation_commands(schema): + group_names = used_names.setdefault(operation.group_name, {}) + command_name = _dedupe_command_name(operation.command_name, group_names, operation) + if command_name != operation.command_name: + operation = replace(operation, command_name=command_name) + operations.append(operation) + return operations + + +def operation_map(schema: dict[str, Any]) -> dict[str, OperationCommand]: + """Return operations keyed by canonical ``group.command`` name.""" + + return {operation.operation_name: operation for operation in operation_commands(schema)} + + +def operation_aliases(operation_name: str) -> set[str]: + """Return Python-friendly aliases for a canonical operation name.""" + + aliases = {operation_name} + aliases.add(operation_name.replace("-", "_")) + aliases.add(operation_name.replace("_", "-")) + if "." in operation_name: + group, command = operation_name.split(".", 1) + aliases.add(f"{group.replace('-', '_')}.{command.replace('-', '_')}") + aliases.add(f"{group.replace('_', '-')}.{command.replace('_', '-')}") + return aliases + + +def resolve_operation( + operations: dict[str, OperationCommand], operation_name: str +) -> OperationCommand: + """Resolve canonical or Python-friendly operation names.""" + + candidates = [operation_name, operation_name.replace("_", "-"), operation_name.replace("-", "_")] + if "." in operation_name: + group, command = operation_name.split(".", 1) + candidates.extend( + [ + f"{group.replace('_', '-')}.{command.replace('_', '-')}", + f"{group.replace('-', '_')}.{command.replace('-', '_')}", + ] + ) + for candidate in candidates: + if candidate in operations: + return operations[candidate] + aliases: dict[str, OperationCommand] = {} + for name, operation in operations.items(): + for alias in operation_aliases(name): + aliases.setdefault(alias, operation) + if operation_name in aliases: + return aliases[operation_name] + raise KeyError(f"Unknown Tangle API operation: {operation_name}") + + +def _iter_operation_commands(schema: dict[str, Any]) -> list[OperationCommand]: + """Convert OpenAPI path/method entries into normalized operation specs.""" + + operations: list[OperationCommand] = [] + paths = schema.get("paths", {}) + if not isinstance(paths, dict): + return operations + + for path, path_item in sorted(paths.items()): + if not isinstance(path_item, dict): + continue + path_level_parameters = path_item.get("parameters") or [] + for method, operation in sorted(path_item.items(), key=_method_sort_key): + method_lower = method.lower() + if method_lower not in SUPPORTED_METHODS or not isinstance(operation, dict): + continue + + group_name = _operation_group_name(operation, path) + command_name = _operation_command_name(method_lower, path, group_name) + parameters = _operation_parameters( + schema, path_level_parameters, operation, path + ) + has_request_body, body_parameters = _request_body_parameters( + schema, operation, {p.local_name for p in parameters} + ) + operations.append( + OperationCommand( + group_name=group_name, + command_name=command_name, + method=method_lower.upper(), + path=path, + operation=operation, + parameters=tuple(parameters + body_parameters), + has_request_body=has_request_body, + ) + ) + + return operations + + +def _operation_group_name(operation: dict[str, Any], path: str) -> str: + """Choose the CLI/client group from the resource path, falling back to tags.""" + + for part in _path_parts(path): + if part != "api" and not _is_path_param(part): + return _normalize_name(part) + + tags = operation.get("tags") + if isinstance(tags, list) and tags: + return _normalize_name(str(tags[0])) + return "api" + + +def _operation_command_name(method: str, path: str, group_name: str) -> str: + """Derive a readable command name from HTTP method and path shape.""" + + parts = _path_parts(path) + parts_without_api = parts[1:] if parts and parts[0] == "api" else parts + + resource_index = None + for index, part in enumerate(parts_without_api): + if _normalize_name(part) == group_name: + resource_index = index + break + + if resource_index is None: + for index, part in enumerate(parts_without_api): + if not _is_path_param(part): + resource_index = index + break + + remainder = ( + parts_without_api[resource_index + 1 :] + if resource_index is not None + else parts_without_api + ) + path_param_count = sum(1 for part in remainder if _is_path_param(part)) + static_segments = [_normalize_name(part) for part in remainder if not _is_path_param(part)] + + if static_segments: + return "-".join(static_segments) + + if path_param_count == 0: + return "list" if method == "get" else _HTTP_METHOD_NAMES.get(method, method) + + return _HTTP_METHOD_NAMES.get(method, method) + + +def _operation_parameters( + schema: dict[str, Any], + path_level_parameters: list[Any], + operation: dict[str, Any], + path: str, +) -> list[CliParameter]: + """Collect OpenAPI path/query params for CLI positionals and client kwargs.""" + + parameters: list[CliParameter] = [] + used_names: set[str] = {"base_url", "token", "auth_header", "header", "headers", "body"} + operation_parameters = list(path_level_parameters) + list(operation.get("parameters") or []) + + for parameter in operation_parameters: + parameter = _resolve_ref(schema, parameter) + if not isinstance(parameter, dict): + continue + location = parameter.get("in") + if location not in {"path", "query"}: + continue + original_name = str(parameter.get("name") or "value") + required = bool(parameter.get("required") or location == "path") + parameter_schema = _unwrap_nullable_schema( + schema, parameter.get("schema") or {} + ) + default = parameter_schema.get("default") if isinstance(parameter_schema, dict) else None + description = str(parameter.get("description") or "") + local_name = _safe_identifier(original_name, used_names, location) + parameters.append( + CliParameter( + original_name=original_name, + local_name=local_name, + location=location, # type: ignore[arg-type] + python_type=_schema_to_python_type(schema, parameter_schema), + required=required, + default=default, + description=description, + ) + ) + + for original_name in re.findall(r"{([^}]+)}", path): + if any(p.location == "path" and p.original_name == original_name for p in parameters): + continue + local_name = _safe_identifier(original_name, used_names, "path") + parameters.append( + CliParameter( + original_name=original_name, + local_name=local_name, + location="path", + python_type=str, + required=True, + ) + ) + + return parameters + + +def _request_body_parameters( + schema: dict[str, Any], operation: dict[str, Any], used_names: set[str] | None = None +) -> tuple[bool, list[CliParameter]]: + """Expose simple JSON object body fields as CLI options/client kwargs.""" + + request_body = _resolve_ref(schema, operation.get("requestBody") or {}) + if not isinstance(request_body, dict) or not request_body: + return False, [] + + body_schema = _json_request_body_schema(schema, request_body) + if not body_schema: + return True, [] + + body_schema = _flatten_schema(schema, body_schema) + properties = body_schema.get("properties") or {} + if not isinstance(properties, dict): + return True, [] + + required_fields = set(body_schema.get("required") or []) + used_names = set(used_names or set()) | {"base_url", "token", "auth_header", "header", "headers", "body"} + parameters: list[CliParameter] = [] + for original_name, property_schema in sorted(properties.items()): + property_schema = _flatten_schema(schema, property_schema) + if not _is_simple_schema(schema, property_schema): + continue + local_name = _safe_identifier(str(original_name), used_names, "body") + default = property_schema.get("default") if isinstance(property_schema, dict) else None + parameters.append( + CliParameter( + original_name=str(original_name), + local_name=local_name, + location="body", + python_type=_schema_to_python_type(schema, property_schema), + required=str(original_name) in required_fields, + default=default, + description=str(property_schema.get("description") or ""), + ) + ) + return True, parameters + + +def _json_request_body_schema( + schema: dict[str, Any], request_body: dict[str, Any] +) -> dict[str, Any] | None: + """Return the JSON media-type schema for a request body, if any.""" + + content = request_body.get("content") or {} + if not isinstance(content, dict): + return None + media = content.get("application/json") + if media is None: + media = next( + ( + value + for key, value in content.items() + if key == "application/*+json" or key.endswith("+json") + ), + None, + ) + if not isinstance(media, dict): + return None + media_schema = media.get("schema") + if not isinstance(media_schema, dict): + return None + return _resolve_ref(schema, media_schema) + + +def _flatten_schema(schema: dict[str, Any], value: Any) -> dict[str, Any]: + """Merge simple ``allOf`` object schemas so body fields can become options.""" + + value = _unwrap_nullable_schema(schema, value) + if not isinstance(value, dict): + return {} + if "allOf" not in value: + return value + + flattened: dict[str, Any] = {k: v for k, v in value.items() if k != "allOf"} + properties: dict[str, Any] = {} + required: list[str] = [] + for item in value.get("allOf") or []: + item = _flatten_schema(schema, item) + properties.update(item.get("properties") or {}) + required.extend(item.get("required") or []) + properties.update(flattened.get("properties") or {}) + required.extend(flattened.get("required") or []) + if properties: + flattened["properties"] = properties + if required: + flattened["required"] = sorted(set(required)) + return flattened + + +def _is_simple_schema(schema_doc: dict[str, Any], schema: Any) -> bool: + """Return true for scalar/list types safe to expose as CLI options.""" + + schema = _unwrap_nullable_schema(schema_doc, schema) + if not isinstance(schema, dict): + return False + schema_type = schema.get("type") + if schema_type in {"string", "integer", "number", "boolean"}: + return True + if schema_type == "array": + return _is_simple_schema(schema_doc, schema.get("items") or {}) + return False + + +def _schema_to_python_type(schema_doc: dict[str, Any], schema: Any) -> Any: + """Map a small OpenAPI schema subset to Python annotations for Cyclopts.""" + + schema = _unwrap_nullable_schema(schema_doc, schema) + if not isinstance(schema, dict): + return str + schema_type = schema.get("type") + if schema_type == "integer": + return int + if schema_type == "number": + return float + if schema_type == "boolean": + return bool + if schema_type == "array": + return list[_schema_to_python_type(schema_doc, schema.get("items") or {})] + return str + + +def _method_sort_key(item: tuple[str, Any]) -> tuple[int, str]: + method = item[0].lower() + return (_METHOD_PRIORITY.get(method, 100), method) + + +def _path_parts(path: str) -> list[str]: + return [part for part in path.strip("/").split("/") if part] + + +def _is_path_param(part: str) -> bool: + return part.startswith("{") and part.endswith("}") + + +def _normalize_name(value: str) -> str: + """Normalize OpenAPI tag/path text to kebab-case CLI names.""" + + value = value.strip().replace("_", "-").replace(" ", "-") + value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value) + value = re.sub(r"[^A-Za-z0-9-]+", "-", value) + value = re.sub(r"-+", "-", value).strip("-").lower() + return value or "api" + + +def _safe_identifier(original: str, used_names: set[str], prefix: str) -> str: + """Convert OpenAPI parameter names into unique Python identifiers.""" + + name = _normalize_name(original).replace("-", "_") + if not name or name[0].isdigit() or keyword.iskeyword(name): + name = f"{prefix}_{name or 'value'}" + candidate = name + suffix = 2 + while candidate in used_names: + candidate = f"{name}_{suffix}" + suffix += 1 + used_names.add(candidate) + return candidate + + +def _dedupe_command_name( + command_name: str, + used_names: dict[str, OperationCommand], + operation: OperationCommand, +) -> str: + """Avoid command collisions within a resource group.""" + + existing = used_names.get(command_name) + if existing is None or _same_operation(existing, operation): + used_names[command_name] = operation + return command_name + + method_prefix = operation.method.lower() + candidate = f"{method_prefix}-{command_name}" + existing = used_names.get(candidate) + if existing is None or _same_operation(existing, operation): + used_names[candidate] = operation + return candidate + + path_suffix = "-".join(_normalize_name(part) for part in _path_parts(operation.path)) + candidate = f"{method_prefix}-{path_suffix}" + suffix = 2 + while candidate in used_names and not _same_operation(used_names[candidate], operation): + candidate = f"{method_prefix}-{path_suffix}-{suffix}" + suffix += 1 + used_names[candidate] = operation + return candidate + + +def _same_operation(left: OperationCommand, right: OperationCommand) -> bool: + return left.method == right.method and left.path == right.path + + +def _unwrap_nullable_schema(schema: dict[str, Any], value: Any) -> Any: + """Resolve refs and reduce nullable unions to their non-null schema.""" + + value = _resolve_ref(schema, value) + if not isinstance(value, dict): + return value + + schema_type = value.get("type") + if isinstance(schema_type, list): + non_null_types = [item for item in schema_type if item != "null"] + if len(non_null_types) == 1: + value = {**value, "type": non_null_types[0]} + + for union_key in ("anyOf", "oneOf"): + variants = value.get(union_key) + if not isinstance(variants, list): + continue + for variant in variants: + variant = _resolve_ref(schema, variant) + if not isinstance(variant, dict) or variant.get("type") == "null": + continue + metadata = {k: v for k, v in value.items() if k not in {union_key, "type"}} + return {**variant, **metadata} + return value + + +def _resolve_ref(schema: dict[str, Any], value: Any) -> Any: + """Resolve local OpenAPI ``$ref`` pointers; leave unsupported refs untouched.""" + + if not isinstance(value, dict) or "$ref" not in value: + return value + ref = value["$ref"] + if not isinstance(ref, str) or not ref.startswith("#/"): + return value + current: Any = schema + for part in ref[2:].split("/"): + part = part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, dict): + return value + current = current.get(part) + return current if current is not None else value diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py new file mode 100644 index 0000000..b128070 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -0,0 +1,461 @@ +"""HTTP transport helpers shared by the OpenAPI CLI and programmatic client.""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.parse +from pathlib import Path +from typing import Any + +import httpx + +DEFAULT_API_URL = "http://localhost:8000" +DEFAULT_TIMEOUT_SECONDS = 30.0 +_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +_MISSING = object() +_SENSITIVE_HEADER_NAMES = {"authorization", "cloud-auth", "cookie", "x-api-key"} +_SENSITIVE_KEY_RE = re.compile( + r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential|pre[-_]?signed[-_]?url|signed[-_]?url)", + re.IGNORECASE, +) +_REDACTED = "" +_REDACTED_DOCUMENT = "" +_OPAQUE_DOCUMENT_KEY_NAMES = { + "component_yaml", + "dockerfile", + "manifest", + "pipeline_yaml", + "text", + "yaml", +} + + +def tangle_verbose_enabled() -> bool: + value = os.environ.get("TANGLE_VERBOSE") + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _redact_headers(headers: dict[str, Any] | None) -> dict[str, Any]: + redacted: dict[str, Any] = {} + for name, value in (headers or {}).items(): + normalized_name = name.lower() + redacted[name] = ( + _REDACTED + if normalized_name in _SENSITIVE_HEADER_NAMES or _SENSITIVE_KEY_RE.search(name) + else value + ) + return redacted + + +def _redact_sensitive_values(value: Any, key: str | None = None) -> Any: + if key and _SENSITIVE_KEY_RE.search(key): + return _REDACTED + if key and key.lower() in _OPAQUE_DOCUMENT_KEY_NAMES and isinstance(value, str) and value: + return _REDACTED_DOCUMENT + if isinstance(value, dict): + return {str(k): _redact_sensitive_values(v, str(k)) for k, v in value.items()} + if isinstance(value, list): + return [_redact_sensitive_values(item) for item in value] + return value + + +def _safe_json_text(value: Any) -> str: + redacted = _redact_sensitive_values(value) + try: + return json.dumps(redacted, indent=2, sort_keys=True, default=str) + except TypeError: + return str(redacted) + + +def _content_to_text(content: bytes | str | None) -> str: + if content is None: + return "" + if isinstance(content, bytes): + if not content: + return "" + text = content.decode("utf-8", errors="replace") + else: + text = content + if not text: + return "" + try: + parsed = json.loads(text) + except Exception: + return text + return _safe_json_text(parsed) + + +def log_http_exchange( + logger: Any, + *, + method: str, + url: str, + request_headers: dict[str, Any] | None = None, + request_body: Any = None, + response_status: int | None = None, + response_headers: dict[str, Any] | None = None, + response_body: bytes | str | None = None, +) -> None: + """Log a redacted HTTP exchange for TANGLE_VERBOSE diagnostics.""" + + emit = getattr(logger, "info", None) + if not callable(emit): + emit = lambda message: print(message, file=sys.stderr, flush=True) + emit(f"[tangle-api] request: {method} {url}") + emit(f"[tangle-api] request headers: {_safe_json_text(_redact_headers(request_headers))}") + if isinstance(request_body, (bytes, str)) or request_body is None: + request_body_text = _content_to_text(request_body) + else: + request_body_text = _safe_json_text(request_body) + emit(f"[tangle-api] request body: {request_body_text}") + if response_status is not None: + emit(f"[tangle-api] response status: {response_status}") + if response_headers is not None: + emit(f"[tangle-api] response headers: {_safe_json_text(_redact_headers(response_headers))}") + if response_body is not None: + emit(f"[tangle-api] response body: {_content_to_text(response_body)}") + + +def default_base_url() -> str: + configured_url = os.environ.get("TANGLE_API_URL") + if configured_url: + return _normalize_base_url(configured_url) + if _ambient_auth_env_present(): + raise SystemExit( + "TANGLE_API_URL is required when Tangle auth environment variables " + f"are set; refusing to send credentials to default {DEFAULT_API_URL}" + ) + return _normalize_base_url(DEFAULT_API_URL) + + +def _ambient_auth_env_present() -> bool: + return any( + os.environ.get(name) + for name in ( + "TANGLE_API_AUTH_HEADER", + "TANGLE_AUTH_HEADER", + "TANGLE_API_HEADERS", + "TANGLE_API_TOKEN", + ) + ) + + +def default_token() -> str | None: + return os.environ.get("TANGLE_API_TOKEN") or None + + +def default_auth_header() -> str | None: + return os.environ.get("TANGLE_API_AUTH_HEADER") or os.environ.get("TANGLE_AUTH_HEADER") or None + + +def _normalize_base_url(base_url: str) -> str: + base_url = base_url.strip().rstrip("/") + if base_url.endswith("/openapi.json"): + base_url = base_url[: -len("/openapi.json")] + return base_url.rstrip("/") + + +def _openapi_url(base_url: str) -> str: + base_url = base_url.strip().rstrip("/") + if base_url.endswith("/openapi.json"): + return base_url + return urllib.parse.urljoin(base_url + "/", "openapi.json") + + +def _request_headers( + token: str | None, + cli_header_entries: list[str] | str | None, + cli_auth_header: str | None, + extra_headers: dict[str, str] | None = None, + *, + include_env_credentials: bool = True, +) -> dict[str, str]: + """Build request headers without printing or otherwise exposing secrets. + + Precedence, lowest to highest: + default Accept header, ``TANGLE_API_HEADERS``, auth env vars, + bearer token, explicit auth header, CLI/header entries, explicit mapping. + """ + + headers = {"Accept": "application/json"} + if include_env_credentials: + headers.update(_headers_from_env()) + env_auth_header = default_auth_header() + if env_auth_header: + headers["Authorization"] = _normalize_auth_header( + env_auth_header, "TANGLE_API_AUTH_HEADER" + ) + token = token or default_token() + if token: + headers["Authorization"] = f"Bearer {token}" + if cli_auth_header: + headers["Authorization"] = _normalize_auth_header(cli_auth_header, "--auth-header") + headers.update(_parse_header_entries(_header_entries(cli_header_entries), "--header")) + if extra_headers: + for name, value in extra_headers.items(): + _validate_header(name, str(value), "headers") + headers[name] = str(value) + return headers + + +def _normalize_auth_header(raw: str, source: str) -> str: + """Accept either an Authorization value or ``Authorization: value``.""" + + value = raw.strip() + if value.lower().startswith("authorization:"): + value = value.split(":", 1)[1].strip() + if not value or "\n" in value or "\r" in value: + raise SystemExit(f"Invalid {source}; expected an authorization header value") + return value + + +def _headers_from_env() -> dict[str, str]: + raw = os.environ.get("TANGLE_API_HEADERS") + if not raw or not raw.strip(): + return {} + return _parse_header_entries(_env_header_entries(raw), "TANGLE_API_HEADERS") + + +def _env_header_entries(raw: str) -> list[str]: + """Parse env headers as JSON object/list or newline-separated entries.""" + + raw = raw.strip() + if raw[0] in "[{": + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise SystemExit("Invalid TANGLE_API_HEADERS JSON") from exc + if isinstance(parsed, dict): + return [f"{name}: {value}" for name, value in parsed.items()] + if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed): + return parsed + raise SystemExit("TANGLE_API_HEADERS must be a JSON object or string list") + return [line.strip() for line in raw.splitlines() if line.strip()] + + +def _header_entries(entries: list[str] | str | None) -> list[str]: + if entries is None: + return [] + if isinstance(entries, str): + return [entries] + return list(entries) + + +def _parse_header_entries(entries: list[str], source: str) -> dict[str, str]: + headers: dict[str, str] = {} + for entry in entries: + if ":" in entry: + name, value = entry.split(":", 1) + elif "=" in entry: + name, value = entry.split("=", 1) + else: + raise SystemExit(f"Invalid {source} entry; expected 'Name: value'") + name = name.strip() + value = value.strip() + _validate_header(name, value, source) + headers[name] = value + return headers + + +def _validate_header(name: str, value: str, source: str) -> None: + if not name or not _HEADER_NAME_RE.fullmatch(name) or "\n" in value or "\r" in value: + raise SystemExit(f"Invalid {source} header name or value") + + +def request_operation( + operation: Any, + values: dict[str, Any], + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header_entries: list[str] | str | None = None, + headers: dict[str, str] | None = None, + body: Any = _MISSING, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + allow_body_file_references: bool = False, + include_env_credentials: bool = True, +) -> httpx.Response: + """Dispatch one normalized OpenAPI operation as an HTTP request. + + ``values`` contains operation params using either generated Python names or + original OpenAPI names. The returned response has already had + ``raise_for_status()`` applied, matching the generated CLI behavior. + """ + + method, url, request_headers, content = build_operation_request( + operation, + values, + base_url=base_url, + token=token, + auth_header=auth_header, + header_entries=header_entries, + headers=headers, + body=body, + allow_body_file_references=allow_body_file_references, + include_env_credentials=include_env_credentials, + ) + response = httpx.request( + method, + url, + content=content, + headers=request_headers, + timeout=timeout, + ) + if tangle_verbose_enabled(): + log_http_exchange( + None, + method=method, + url=url, + request_headers=request_headers, + request_body=content, + response_status=response.status_code, + response_headers=dict(response.headers), + response_body=response.text, + ) + response.raise_for_status() + return response + + +def build_operation_request( + operation: Any, + values: dict[str, Any], + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header_entries: list[str] | str | None = None, + headers: dict[str, str] | None = None, + body: Any = _MISSING, + allow_body_file_references: bool = False, + include_env_credentials: bool = True, +) -> tuple[str, str, dict[str, str], bytes | None]: + """Build method, URL, headers, and body bytes for an operation.""" + + base_url = _normalize_base_url(base_url or default_base_url()) + path = operation.path + query: dict[str, Any] = {} + body_fields: dict[str, Any] = {} + remaining = dict(values) + + for parameter in operation.parameters: + if parameter.local_name in remaining: + value = remaining.pop(parameter.local_name) + elif parameter.original_name in remaining: + value = remaining.pop(parameter.original_name) + else: + if parameter.location == "path" and parameter.required: + raise TypeError(f"Missing required path parameter: {parameter.local_name}") + if parameter.location in {"query", "body"} and parameter.required: + # A required body field can also be satisfied by the generic body. + if parameter.location == "body" and body is not _MISSING and body is not None: + continue + raise TypeError(f"Missing required parameter: {parameter.local_name}") + continue + if value is None: + continue + if parameter.location == "path": + path = path.replace( + "{" + parameter.original_name + "}", + urllib.parse.quote(str(value), safe=""), + ) + elif parameter.location == "query": + query[parameter.original_name] = value + elif parameter.location == "body": + body_fields[parameter.original_name] = value + + if remaining: + names = ", ".join(sorted(remaining)) + raise TypeError(f"Unexpected parameter(s) for {operation.group_name}.{operation.command_name}: {names}") + + url = _join_operation_url(base_url, path) + if query: + url = f"{url}?{_urlencode_query(query)}" + + request_body = None + if operation.has_request_body: + if body is _MISSING: + body = None + request_body = ( + _coerce_body_argument( + body, allow_file_references=allow_body_file_references + ) + if body is not None + else None + ) + if body_fields: + if request_body is None: + request_body = {} + if not isinstance(request_body, dict): + raise TypeError("body must be a JSON object when body field parameters are used") + request_body.update(body_fields) + + request_headers = _request_headers( + token, + header_entries, + auth_header, + headers, + include_env_credentials=include_env_credentials, + ) + content = _body_to_content(request_body) + if content is not None and "Content-Type" not in request_headers: + request_headers["Content-Type"] = "application/json" + return operation.method, url, request_headers, content + + +def _join_operation_url(base_url: str, path: str) -> str: + """Join a schema path to ``base_url`` without allowing origin changes.""" + + parsed_path = urllib.parse.urlparse(path) + if parsed_path.scheme or parsed_path.netloc: + raise ValueError(f"OpenAPI operation path must be relative: {path!r}") + return urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + + +def _urlencode_query(query: dict[str, Any]) -> str: + """Encode query params, preserving repeated values for list options.""" + + items: list[tuple[str, Any]] = [] + for key, value in query.items(): + if isinstance(value, (list, tuple)): + items.extend((key, item) for item in value) + else: + items.append((key, value)) + return urllib.parse.urlencode(items, doseq=True) + + +def _load_body_argument(body: str) -> Any: + """Parse a CLI ``--body`` value; leading ``@`` reads JSON from a file.""" + + if body.startswith("@"): + body = Path(body[1:]).expanduser().read_text(encoding="utf-8") + try: + return json.loads(body) + except json.JSONDecodeError as exc: + raise SystemExit(f"Invalid JSON body: {exc}") from exc + + +def _coerce_body_argument(body: Any, *, allow_file_references: bool = False) -> Any: + if not isinstance(body, str): + return body + if allow_file_references: + return _load_body_argument(body) + try: + return json.loads(body) + except json.JSONDecodeError: + return body + + +def _body_to_content(request_body: Any) -> bytes | None: + if request_body is None: + return None + if isinstance(request_body, bytes): + return request_body + if isinstance(request_body, bytearray): + return bytes(request_body) + return json.dumps(request_body).encode("utf-8") diff --git a/packages/tangle-cli/src/tangle_cli/args_container.py b/packages/tangle-cli/src/tangle_cli/args_container.py new file mode 100644 index 0000000..65ae6f2 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/args_container.py @@ -0,0 +1,244 @@ +"""CLI argument resolution with optional YAML/JSON config files. + +This module provides generic config-file behavior shared by Tangle CLI +commands: load one or more config objects, merge each with parsed CLI +arguments, and keep explicit CLI values higher precedence than config values. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from enum import Enum +from pathlib import Path +from typing import Any, cast + +import yaml + +from tangle_cli.logger import Logger, get_default_logger +from tangle_cli.utils import apply_defaults + + +class ConfigFileError(Exception): + """Raised when there is an error loading or resolving a config file.""" + + +class ArgsContainer: + """Container for resolved CLI arguments with config-file defaults.""" + + def __init__(self, resolved: dict[str, Any], raw_config: dict[str, Any]): + self._config = raw_config + for key, value in resolved.items(): + setattr(self, key, value) + + def __getattr__(self, name: str) -> Any: + raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") + + def get(self, key: str, cli_value: Any = None, cli_default: Any = None) -> Any: + """Return a resolved value while preserving explicit CLI precedence.""" + + if cli_value != cli_default: + return cli_value + if key in self._config: + return self._config[key] + return cli_value + + def to_dict(self) -> dict[str, Any]: + """Return resolved public values as a dictionary.""" + + return {key: value for key, value in vars(self).items() if key != "_config"} + + @staticmethod + def _load_config_file( + config_path: str | Path | None, + logger: Logger | None = None, + ) -> list[dict[str, Any]]: + """Load a YAML/JSON config file as a list of config dictionaries. + + Supported shapes are a single object, a list of objects, or an object + with ``_defaults`` and ``configs`` where defaults are applied to each + config entry. Other top-level keys are ignored, which lets YAML files + use anchors/shared helper sections. + """ + + log = logger or get_default_logger() + if config_path is None: + return [{}] + + path = Path(config_path) + if not path.exists(): + raise ConfigFileError(f"Config file not found: {config_path}") + + try: + with path.open(encoding="utf-8") as f: + if path.suffix in (".yaml", ".yml"): + parsed = yaml.safe_load(f) + if parsed is None: + return [{}] + else: + parsed = json.load(f) + except (OSError, json.JSONDecodeError, yaml.YAMLError) as exc: + raise ConfigFileError(f"Error loading config file: {exc}") from exc + + if isinstance(parsed, dict): + parsed_dict = cast(dict[str, Any], parsed) + if "configs" in parsed_dict: + defaults = parsed_dict.get("_defaults", {}) + configs_list = parsed_dict.get("configs", []) + if not isinstance(defaults, dict): + raise ConfigFileError( + f"_defaults must be an object, got {type(defaults).__name__}" + ) + if not isinstance(configs_list, list): + raise ConfigFileError( + f"configs must be a list, got {type(configs_list).__name__}" + ) + for index, item in enumerate(configs_list): + if not isinstance(item, dict): + raise ConfigFileError( + "configs entry " + f"{index} must be an object, got {type(item).__name__}" + ) + merged = apply_defaults(configs_list, defaults) + assert isinstance(merged, list) + log.info(f"Loaded config: {path} ({len(merged)} configs with defaults)") + return merged + log.info(f"Loaded config: {path} (1 config)") + return [parsed_dict] + + if isinstance(parsed, list): + for index, item in enumerate(cast(list[Any], parsed)): + if not isinstance(item, dict): + raise ConfigFileError( + "Config file entry " + f"{index} must be an object, got {type(item).__name__}" + ) + configs = cast(list[dict[str, Any]], parsed) + log.info(f"Loaded config: {path} ({len(configs)} configs)") + return configs + + raise ConfigFileError( + "Config file must contain an object or list of objects, " + f"got {type(parsed).__name__}" + ) + + @staticmethod + def _make_json_converter(field_name: str) -> Callable[[Any], Any]: + """Create a converter that accepts parsed JSON or JSON text.""" + + def convert(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (dict, list)): + return cast(Any, value) + if isinstance(value, str): + if value in ("", "{}", "[]", "null"): + return None + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise ConfigFileError(f"Invalid JSON for {field_name}: {exc}") from exc + raise ConfigFileError( + f"{field_name} must be a dict, list, or JSON string, " + f"got {type(value).__name__}" + ) + + return convert + + @staticmethod + def _make_enum_converter(field_name: str, enum_type: type[Enum]) -> Callable[[Any], Any]: + """Create a converter that accepts enum values by string.""" + + def convert(value: Any) -> Any: + if isinstance(value, str): + try: + return enum_type(value) + except ValueError as exc: + valid_values = [member.value for member in enum_type] + raise ConfigFileError( + f"Invalid value '{value}' for {field_name}. " + f"Valid values: {valid_values}" + ) from exc + return value + + return convert + + @staticmethod + def _resolve(config: dict[str, Any], **kwargs: Any) -> ArgsContainer: + """Resolve CLI args against a single config dict. + + Field specs can be: + - ``(cli_value,)``: required field, config key is parameter name; + - ``(cli_value, default)``: optional field; + - ``(cli_value, default, converter)``: optional with converter; + - ``(config_key, cli_value, default, is_json)``: explicit key; + - ``(config_key, cli_value, default, is_json, required)``; + - ``(config_key, cli_value, default, is_json, required, converter)``. + """ + + resolved: dict[str, Any] = {} + required_fields: list[str] = [] + + for param_name, spec in kwargs.items(): + converter = None + default_value = None + if len(spec) == 1: + (cli_value,) = spec + config_key = param_name + required_fields.append(param_name) + elif len(spec) == 2: + cli_value, default_value = spec + config_key = param_name + elif len(spec) == 3: + cli_value, default_value, converter = spec + config_key = param_name + elif len(spec) == 4: + config_key, cli_value, default_value, is_json = spec + if is_json: + converter = ArgsContainer._make_json_converter(param_name) + elif len(spec) == 5: + config_key, cli_value, default_value, is_json, required = spec + if is_json: + converter = ArgsContainer._make_json_converter(param_name) + if required: + required_fields.append(param_name) + else: + config_key, cli_value, default_value, is_json, required, converter = spec + if is_json: + converter = ArgsContainer._make_json_converter(param_name) + if required: + required_fields.append(param_name) + + if converter is None and isinstance(default_value, Enum): + converter = ArgsContainer._make_enum_converter(param_name, type(default_value)) + + if cli_value is not None and cli_value != default_value: + value = cli_value + elif config_key in config: + value = config[config_key] + else: + value = cli_value + + resolved[param_name] = converter(value) if converter and value is not None else value + + for field_name in required_fields: + if resolved.get(field_name) is None: + raise ConfigFileError( + f"{field_name} is required (via CLI argument or config file)" + ) + + return ArgsContainer(resolved, config) + + @staticmethod + def load( + config_path: str | Path | None, + logger: Logger | None = None, + **kwargs: Any, + ) -> list[ArgsContainer]: + """Load a config file and resolve CLI args against each config entry.""" + + configs = ArgsContainer._load_config_file(config_path, logger=logger) + return [ArgsContainer._resolve(config, **kwargs) for config in configs] + + +__all__ = ["ArgsContainer", "ConfigFileError"] diff --git a/packages/tangle-cli/src/tangle_cli/artifacts.py b/packages/tangle-cli/src/tangle_cli/artifacts.py new file mode 100644 index 0000000..438bfab --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -0,0 +1,293 @@ +"""Read-only artifact lookup helpers for Tangle pipeline runs. + +This module intentionally resolves artifact metadata only. It does not fetch +signed URLs, download remote objects, write local files, or mutate artifacts. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field, is_dataclass +from typing import Any, Protocol + +from .handler import TangleCliHandler + + +class ArtifactClient(Protocol): + """Subset of the static API client used for read-only artifact lookup.""" + + def get_run_details(self, run_id: str) -> Any: ... + + def get_execution_details(self, execution_id: str) -> Any: ... + + def artifacts_get(self, artifact_id: str) -> Any: ... + + +@dataclass +class ArtifactComponentQuery: + """Filter for selecting artifacts by component name or digest.""" + + name: str | None = None + digest: str | None = None + outputs: list[str] = field(default_factory=list) + + +@dataclass +class ArtifactInfo: + """Resolved artifact metadata from GET /api/artifacts/{id}. + + This dataclass intentionally lives in this native-free module. Generated + response objects are accepted structurally via ``from_response`` so no + ``tangle_api`` import is required. + """ + + id: str + uri: str + key: str = "" + total_size: int = 0 + is_dir: bool = False + hash: str | None = None + created_at: str | None = None + error: str | None = None + local_path: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any], key: str = "") -> ArtifactInfo: + ad = data.get("artifact_data", {}) + return cls( + id=data.get("id", ""), + uri=_mapping_or_attr(ad, "uri", ""), + key=key, + total_size=_mapping_or_attr(ad, "total_size", 0), + is_dir=_mapping_or_attr(ad, "is_dir", False), + hash=_optional_str(_mapping_or_attr(ad, "hash")), + created_at=_optional_str(_mapping_or_attr(ad, "created_at")), + ) + + @classmethod + def from_response(cls, response: Any, *, key: str = "") -> ArtifactInfo: + """Create a flattened artifact DTO from a generated or duck-typed response.""" + + artifact_data = getattr(response, "artifact_data", None) + total_size = _mapping_or_attr(artifact_data, "total_size", 0) + is_dir = _mapping_or_attr(artifact_data, "is_dir", False) + return cls( + id=str(getattr(response, "id", "") or ""), + uri=str(_mapping_or_attr(artifact_data, "uri", "") or ""), + key=key, + total_size=total_size if isinstance(total_size, int) else 0, + is_dir=is_dir if isinstance(is_dir, bool) else False, + hash=_optional_str(_mapping_or_attr(artifact_data, "hash")), + created_at=_optional_str(_mapping_or_attr(artifact_data, "created_at")), + ) + + +class ArtifactManager(TangleCliHandler): + """Read-only artifact metadata manager. + + Downstream packages can inject an already-authenticated client or a lazy + ``client_factory`` (for example, one that applies provider auth). The manager + keeps the same read-only constraints as the module-level helpers: it never + downloads artifact contents, signs URLs, writes files, or mutates artifacts. + """ + + def __init__( + self, + client: ArtifactClient | None = None, + *, + client_factory: Any | None = None, + logger: Any | None = None, + **kwargs: Any, + ) -> None: + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) + + def collect_artifacts( + self, + execution: Any, + tasks_query: dict[str, list[str]], + components_query: list[ArtifactComponentQuery], + prefix: str = "", + ) -> dict[str, str]: + """Collect artifact IDs by walking an enriched execution tree.""" + + artifact_ids: dict[str, str] = {} + task_spec = _mapping_or_attr(execution, "task_spec") + graph_tasks = _mapping_or_attr(task_spec, "graph_tasks", {}) + if not isinstance(graph_tasks, dict): + return artifact_ids + + for task_name, child_task in graph_tasks.items(): + task_name = str(task_name) + key_prefix = f"{prefix}{task_name}" if prefix else task_name + output_filters: list[list[str]] = [] + + for query_name in (task_name, key_prefix): + if query_name in tasks_query: + output_filters.append(tasks_query[query_name]) + break + + child_digest = _mapping_or_attr(child_task, "digest") + child_name = _mapping_or_attr(child_task, "name") + for component in components_query: + if (component.digest and child_digest == component.digest) or ( + component.name and child_name == component.name + ): + output_filters.append(component.outputs) + + out_artifacts = _artifact_id_map(_mapping_or_attr(child_task, "execution_output_artifacts", {})) + if output_filters and out_artifacts: + include_all = any(not output_filter for output_filter in output_filters) + requested_outputs = { + output_name + for output_filter in output_filters + for output_name in output_filter + } + for output_name, artifact_id in out_artifacts.items(): + if include_all or output_name in requested_outputs: + artifact_ids[f"{key_prefix}/{output_name}"] = artifact_id + + if _mapping_or_attr(child_task, "is_graph", False): + child_executions = _mapping_or_attr(execution, "child_executions", {}) + child_execution = child_executions.get(task_name) if isinstance(child_executions, dict) else None + if child_execution: + artifact_ids.update( + self.collect_artifacts( + child_execution, + tasks_query, + components_query, + prefix=f"{key_prefix}/", + ) + ) + + return artifact_ids + + def collect_execution_artifacts( + self, + execution_ids: dict[str, list[str]], + ) -> dict[str, str]: + """Collect artifact IDs directly from execution IDs.""" + + artifact_ids: dict[str, str] = {} + client = self._require_client() + for execution_id, output_filter in execution_ids.items(): + execution = client.get_execution_details(execution_id) + output_artifacts = _artifact_id_map(_mapping_or_attr(execution, "output_artifacts", {})) + for output_name, artifact_id in output_artifacts.items(): + if not output_filter or output_name in output_filter: + artifact_ids[f"{execution_id}/{output_name}"] = artifact_id + return artifact_ids + + def get_artifacts( + self, + run_id: str, + query: dict[str, Any], + ) -> dict[str, ArtifactInfo]: + """Get artifact metadata for tasks/components in a pipeline run. + + Query keys: + - ``tasks``: ``{: []}`` + - ``components``: ``[{"name"|"digest": ..., "outputs": [...]}]`` + - ``executions``: ``{: []}`` + - ``artifact_ids``: ``[, ...]`` + + Empty output lists mean all outputs. Per-artifact lookup failures are + returned as ``ArtifactInfo(error=...)`` entries instead of failing the + whole command. + """ + + artifact_ids: dict[str, str] = {} + + for artifact_id in query.get("artifact_ids", []) or []: + artifact_ids[str(artifact_id)] = str(artifact_id) + + executions_query = query.get("executions", {}) or {} + if executions_query: + artifact_ids.update(self.collect_execution_artifacts(executions_query)) + + tasks_query = query.get("tasks", {}) or {} + components_query_raw = query.get("components", []) or [] + if tasks_query or components_query_raw: + details = self._require_client().get_run_details(run_id) + execution = _mapping_or_attr(details, "execution") + if not execution: + raise RuntimeError("No execution details found for run") + artifact_ids.update( + self.collect_artifacts( + execution, + tasks_query, + _component_queries(components_query_raw), + ) + ) + + artifacts: dict[str, ArtifactInfo] = {} + for key, artifact_id in artifact_ids.items(): + try: + response = self._require_client().artifacts_get(artifact_id) + artifacts[key] = _artifact_info_from_response(response, artifact_id=artifact_id, key=key) + except Exception as exc: + artifacts[key] = ArtifactInfo(id=artifact_id, uri="", key=key, error=str(exc)) + + return artifacts + + @staticmethod + def serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, Any]]: + """Serialize artifact dict to a JSON-friendly list, dropping ``None`` fields.""" + + result: list[dict[str, Any]] = [] + for artifact in artifacts.values(): + data = asdict(artifact) if is_dataclass(artifact) else dict(artifact) + result.append({key: value for key, value in data.items() if value is not None}) + return result + + +def _mapping_or_attr(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def _artifact_id_map(raw_artifacts: Any) -> dict[str, str]: + """Normalize API artifact maps to ``{output_name: artifact_id}``.""" + + if not isinstance(raw_artifacts, dict): + return {} + + artifact_ids: dict[str, str] = {} + for output_name, value in raw_artifacts.items(): + if isinstance(value, str): + artifact_ids[str(output_name)] = value + elif isinstance(value, dict) and value.get("id"): + artifact_ids[str(output_name)] = str(value["id"]) + elif getattr(value, "id", None): + artifact_ids[str(output_name)] = str(value.id) + return artifact_ids + + +def _component_queries(raw_components: list[dict[str, Any]]) -> list[ArtifactComponentQuery]: + return [ + ArtifactComponentQuery( + name=component.get("name"), + digest=component.get("digest"), + outputs=component.get("outputs") or [], + ) + for component in raw_components + ] + + +def _artifact_info_from_response(response: Any, *, artifact_id: str, key: str) -> ArtifactInfo: + if isinstance(response, dict): + return ArtifactInfo.from_dict(response, key=key) + return ArtifactInfo.from_response(response, key=key) + + +__all__ = [ + "ArtifactClient", + "ArtifactComponentQuery", + "ArtifactInfo", + "ArtifactManager", +] diff --git a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py new file mode 100644 index 0000000..a6d59e1 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py @@ -0,0 +1,108 @@ +"""`tangle sdk artifacts` read-only artifact commands.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .cli_helpers import ( + LazyTangleApiClient, + api_arg_specs, + include_env_credentials_for_args, + load_args_or_exit, + print_json, +) +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + LogTypeOption, + TokenOption, +) +from .logger import logger_for_log_type + +QueryOption = Annotated[ + str | None, + Parameter( + name="--query", + alias="-q", + help=( + "JSON query with optional keys: " + "'tasks', 'components', 'executions', and 'artifact_ids'. " + "Empty output lists mean all outputs." + ), + ), +] + +app = App( + name="artifacts", + help="Read artifact metadata for Tangle pipeline runs.", +) + + +@app.command(name="get") +def artifacts_get( + run_id: str | None = None, + *, + query: QueryOption = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Get artifact metadata for tasks/components in a pipeline run.""" + + all_args = load_args_or_exit( + config, + run_id=("run_id", run_id, None, False, True), + query=("query", query, None, True, True), + log_type=(log_type, "console"), + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ) + + results: list[dict[str, Any]] = [] + for args in all_args: + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + client = LazyTangleApiClient( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, base_url), + command_name="artifact commands", + ) + if require_available := getattr(client, "require_available", None): + require_available() + from .artifacts import ArtifactManager + + manager = ArtifactManager(client=client) + try: + artifacts = manager.get_artifacts(args.run_id, args.query) + except RuntimeError as exc: + print_json({"status": "error", "error": str(exc)}) + raise SystemExit(1) from exc + + results.append( + { + "status": "success", + "run_id": args.run_id, + "count": len(artifacts), + "artifacts": ArtifactManager.serialize_artifacts(artifacts), + } + ) + finally: + finalize_logs() + + print_json( + results[0] if len(results) == 1 else {"status": "success", "results": results} + ) diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py new file mode 100644 index 0000000..e9c1fd4 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -0,0 +1,57 @@ +from cyclopts import App + +from . import ( + __version__, + api_cli, + artifacts_cli, + components_cli, + pipeline_runs_cli, + pipelines_cli, + published_components_cli, + quickstart, + secrets_cli, +) + + +def version() -> None: + """Print the installed tangle-cli package version.""" + + print(__version__) + + +def build_sdk_app() -> App: + """Build the SDK command group.""" + + sdk_app = App( + name="sdk", + help="Work with local Tangle SDK resources and scaffolding.", + ) + sdk_app.command(artifacts_cli.app) + sdk_app.command(components_cli.app) + sdk_app.command(pipelines_cli.app) + sdk_app.command(pipeline_runs_cli.app) + sdk_app.command(published_components_cli.app) + sdk_app.command(secrets_cli.app) + return sdk_app + + +def build_app() -> App: + """Build the root CLI app lazily for the current invocation.""" + + app = App( + help="CLI for Tangle, the open-source ML pipeline orchestration platform.", + version=__version__, + ) + app.command(name="version")(version) + app.command(quickstart.app) + app.command(api_cli.build_app()) + app.command(build_sdk_app()) + return app + + +def main() -> None: + build_app()() + + +if __name__ == "__main__": + main() diff --git a/packages/tangle-cli/src/tangle_cli/cli_helpers.py b/packages/tangle-cli/src/tangle_cli/cli_helpers.py new file mode 100644 index 0000000..f2c704b --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/cli_helpers.py @@ -0,0 +1,116 @@ +"""Shared helpers for Tangle CLI command modules.""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any + +from .args_container import ArgsContainer, ConfigFileError + + +def load_args_or_exit(config: str | None, **kwargs: Any) -> list[ArgsContainer]: + """Load ArgsContainer values from CLI/config specs, exiting with CLI errors.""" + + try: + return ArgsContainer.load(config, **kwargs) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + + +def print_json(payload: object) -> None: + """Print a stable pretty JSON payload for CLI output.""" + + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def load_config_or_exit(config: str | None) -> dict[str, object]: + """Load the first YAML/JSON config mapping for commands with custom merging.""" + + if config is None: + return {} + try: + configs = ArgsContainer._load_config_file(config) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + return configs[0] if configs else {} + + +def optional_path(value: str | pathlib.Path | object | None) -> pathlib.Path | None: + """Convert a CLI/config path value to Path when present.""" + + if isinstance(value, pathlib.Path): + return value + if isinstance(value, str): + return pathlib.Path(value) + return None + + +def api_arg_specs( + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, +) -> dict[str, tuple[Any, ...]]: + """Build ArgsContainer specs for common API connection options.""" + + return { + "base_url": (base_url, None), + "token": (token, None), + "auth_header": (auth_header, None), + "header": (header, None), + } + + +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 + constructing ``TangleApiClient`` until an API method is actually accessed, + while keeping CLI-friendly error wording in the CLI helper layer. + """ + + def __init__(self, *, command_name: str, **client_kwargs: Any) -> None: + self.command_name = command_name + self.client_kwargs = client_kwargs + self._client: Any | None = None + + def _get_client(self) -> Any: + if self._client is None: + try: + from .api_transport import DEFAULT_TIMEOUT_SECONDS + from .client import TangleApiClient + 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." + ) from exc + raise + + kwargs = dict(self.client_kwargs) + kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) + self._client = TangleApiClient(**kwargs) + return self._client + + def require_available(self) -> None: + """Materialize the client so CLI commands fail before native helper imports.""" + + self._get_client() + + def __getattr__(self, name: str) -> Any: + return getattr(self._get_client(), name) + + +def include_env_credentials_for_args(args: ArgsContainer, cli_base_url: str | None) -> bool: + """Suppress ambient credentials when base_url came from config, not CLI. + + Explicit config/CLI token/auth/header values remain present on *args* and are + passed through by callers. This helper only controls environment fallback. + """ + + config_base_url = getattr(args, "_config", {}).get("base_url") + return not (cli_base_url is None and config_base_url is not None) diff --git a/packages/tangle-cli/src/tangle_cli/cli_options.py b/packages/tangle-cli/src/tangle_cli/cli_options.py new file mode 100644 index 0000000..f6080ed --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/cli_options.py @@ -0,0 +1,52 @@ +"""Shared Cyclopts option annotations for Tangle CLI commands.""" + +from __future__ import annotations + +from typing import Annotated + +from cyclopts import Parameter + +from .api_transport import DEFAULT_API_URL + +BaseUrlOption = Annotated[ + str | None, + Parameter( + help=( + "Tangle API base URL. Defaults to TANGLE_API_URL, then " + f"{DEFAULT_API_URL}." + ) + ), +] +TokenOption = Annotated[ + str | None, + Parameter(help="Bearer token. Defaults to TANGLE_API_TOKEN."), +] +AuthHeaderOption = Annotated[ + str | None, + Parameter( + help=( + "Authorization header value, e.g. 'Bearer TOKEN' or 'Basic BASE64'. " + "Defaults to TANGLE_API_AUTH_HEADER or TANGLE_AUTH_HEADER." + ) + ), +] +HeaderOption = Annotated[ + list[str] | None, + Parameter( + name="--header", + alias="-H", + help=( + "Custom request header as 'Name: value'. Repeat for multiple. " + "Applied after TANGLE_API_HEADERS." + ), + negative_iterable=(), + ), +] +ConfigOption = Annotated[ + str | None, + Parameter(help="YAML/JSON config file providing command defaults."), +] +LogTypeOption = Annotated[ + str, + Parameter(help="Log output: console, none, file."), +] diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py new file mode 100644 index 0000000..b8440b9 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -0,0 +1,677 @@ +"""Static public Tangle API client. + +``TangleApiClient`` is the stable wrapper class consumed by downstream tools. +Endpoint methods are generated offline into :mod:`tangle_api.generated.operations` +from the checked-in OpenAPI snapshot; handwritten methods in this file keep the +higher-level semantic helpers that downstream callers use. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterable, Mapping +from dataclasses import asdict, is_dataclass +from email.utils import parsedate_to_datetime +from typing import Any +from urllib.parse import quote, urljoin, urlparse + +import requests + +from .api_transport import ( + DEFAULT_TIMEOUT_SECONDS, + _join_operation_url, + _normalize_base_url, + _request_headers, + default_base_url, + log_http_exchange, + tangle_verbose_enabled, +) +from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse +from tangle_api.generated.operations import GeneratedTangleApiOperations +from .logger import Logger, _null_logger, get_default_logger +from .models import ( + ComponentInfo, + GraphExecutionState, + PipelineRun, + RunDetails, + TaskSpec, +) + + +class TangleApiClient(GeneratedTangleApiOperations): + """Single public API wrapper for Tangle backends. + + The constructor keeps the historical ``tangle-deploy`` shape while also + accepting the auth/header knobs used by the dynamic-discovery client. No + OpenAPI schema is loaded at runtime; all endpoint wrappers are checked in. + """ + + _REDIRECT_STATUSES = {301, 302, 303, 307, 308} + _MAX_REDIRECTS = 5 + _MAX_RATE_LIMIT_RETRIES = 3 + _RATE_LIMIT_BACKOFF_SECONDS = 1.0 + _MAX_RETRY_AFTER_SECONDS = 60.0 + + def __init__( + self, + base_url: str | None = None, + *, + logger: Logger | None = None, + verbose: bool = False, + headers: Mapping[str, str] | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + session: requests.Session | None = None, + include_env_credentials: bool = True, + ) -> None: + self.base_url = _normalize_base_url(base_url or default_base_url()) + env_verbose = tangle_verbose_enabled() + self.verbose = verbose or env_verbose + self.logger = logger or (get_default_logger() if self.verbose else _null_logger) + self.headers = dict(headers or {}) + self.token = token + self.auth_header = auth_header + self.header = header + self.timeout = timeout + self.session = session or requests.Session() + self.include_env_credentials = include_env_credentials + + def set_verbose(self, enabled: bool) -> None: + """Enable or disable request logging.""" + + self.verbose = enabled + + def _refresh_auth(self) -> None: + """Hook for subclasses to refresh auth before/retry after a request. + + Subclasses commonly mutate ``self.headers`` or session state here. The + base implementation intentionally does nothing. + """ + + def _make_request( + self, + method: str, + path: str, + params: Mapping[str, Any] | None = None, + json_data: Any = None, + **kwargs: Any, + ) -> requests.Response: + """Issue an HTTP request and return the raw ``requests.Response``. + + This method preserves the subclass extension point used by + ``tangle-deploy``: auth can be refreshed by overriding + :meth:`_refresh_auth`, and callers that need streaming can pass standard + ``requests`` keyword arguments such as ``stream=True``. + """ + + if "json" in kwargs and json_data is None: + json_data = kwargs.pop("json") + timeout = kwargs.pop("timeout", self.timeout) + extra_headers = kwargs.pop("headers", None) + url = self._url(path) + clean_params = self._clean_mapping(params) + request_method = method.upper() + + self._refresh_auth() + response = self._request_with_rate_limit_retries( + request_method, + url, + params=clean_params, + json_data=json_data, + extra_headers=extra_headers, + timeout=timeout, + request_kwargs=kwargs, + ) + if response.status_code == 401: + self._refresh_auth() + response = self._request_with_rate_limit_retries( + request_method, + url, + params=clean_params, + json_data=json_data, + extra_headers=extra_headers, + timeout=timeout, + request_kwargs=kwargs, + ) + return response + + def _request_with_rate_limit_retries( + self, + method: str, + url: str, + *, + params: Mapping[str, Any] | None, + json_data: Any, + extra_headers: Mapping[str, str] | None, + timeout: float, + request_kwargs: Mapping[str, Any], + ) -> requests.Response: + response: requests.Response | None = None + for attempt in range(self._MAX_RATE_LIMIT_RETRIES + 1): + response = self._request_with_same_origin_redirects( + method, + url, + params=params, + json_data=json_data, + extra_headers=extra_headers, + timeout=timeout, + request_kwargs=request_kwargs, + ) + if response.status_code != 429 or attempt == self._MAX_RATE_LIMIT_RETRIES: + return response + self._sleep_for_rate_limit(response, attempt) + return response + + def _sleep_for_rate_limit(self, response: requests.Response, attempt: int) -> None: + retry_after = response.headers.get("Retry-After") + delay = self._retry_after_delay(retry_after) + if delay is None: + delay = self._RATE_LIMIT_BACKOFF_SECONDS * (2 ** attempt) + delay = min(delay, self._MAX_RETRY_AFTER_SECONDS) + if self.verbose: + self.logger.info(f"429 rate limited; retrying in {delay:.1f}s") + time.sleep(delay) + + @staticmethod + def _retry_after_delay(value: str | None) -> float | None: + if not value: + return None + try: + return max(0.0, float(value)) + except ValueError: + pass + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if retry_at.tzinfo is None: + return None + return max(0.0, retry_at.timestamp() - time.time()) + + def _request_with_same_origin_redirects( + self, + method: str, + url: str, + *, + params: Mapping[str, Any] | None, + json_data: Any, + extra_headers: Mapping[str, str] | None, + timeout: float, + request_kwargs: Mapping[str, Any], + ) -> requests.Response: + """Send one request, following only same-origin redirects. + + The client may carry custom auth headers/cookies in ``session.headers``. + ``requests`` does not strip those custom credentials on cross-origin + redirects, so redirects are handled manually and constrained to the + original origin. + """ + + current_method = method + current_url = url + current_params = params + current_json = json_data + response: requests.Response | None = None + + for _ in range(self._MAX_REDIRECTS + 1): + request_headers = self._headers(extra_headers) + response = self.session.request( + current_method, + current_url, + params=current_params, + json=current_json, + headers=request_headers, + timeout=timeout, + allow_redirects=False, + **request_kwargs, + ) + if self.verbose: + log_http_exchange( + self.logger, + method=current_method, + url=current_url, + request_headers=request_headers, + request_body=current_json, + response_status=response.status_code, + response_headers=dict(response.headers), + response_body=response.text, + ) + if response.status_code not in self._REDIRECT_STATUSES: + return response + + location = response.headers.get("Location") + if not location: + return response + + next_url = urljoin(response.url, location) + if not self._same_origin(response.url, next_url): + raise requests.HTTPError( + f"Refusing to follow cross-origin redirect from {response.url} to {next_url}", + response=response, + ) + + try: + response.close() + except Exception: + pass + if response.status_code == 303 or ( + response.status_code in {301, 302} and current_method not in {"GET", "HEAD"} + ): + current_method = "GET" + current_json = None + current_url = next_url + current_params = None + + raise requests.TooManyRedirects( + f"Exceeded {self._MAX_REDIRECTS} redirects for {url}", + response=response, + ) + + @staticmethod + def _same_origin(left: str, right: str) -> bool: + left_parts = urlparse(left) + right_parts = urlparse(right) + return ( + left_parts.scheme.lower() == right_parts.scheme.lower() + and left_parts.netloc.lower() == right_parts.netloc.lower() + ) + + def _request_json( + self, + method: str, + path: str, + *, + path_params: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + json_data: Any = None, + response_model: Any = None, + ) -> Any: + formatted_path = self._format_path(path, path_params) + response = self._make_request(method, formatted_path, params=params, json_data=json_data) + response.raise_for_status() + data = self._decode_response(response) + if response_model is not None and isinstance(data, dict): + return response_model.from_dict(data) + if response_model is not None and isinstance(data, list): + return [ + response_model.from_dict(item) if isinstance(item, dict) else item + for item in data + ] + return data + + def _headers(self, extra_headers: Mapping[str, str] | None = None) -> dict[str, str]: + headers = dict(self.headers) + if extra_headers: + headers.update({name: str(value) for name, value in extra_headers.items()}) + return _request_headers( + self.token, + self.header, + self.auth_header, + headers, + include_env_credentials=self.include_env_credentials, + ) + + def _url(self, path: str) -> str: + return _join_operation_url(self.base_url, path) + + @staticmethod + def _format_path(path: str, path_params: Mapping[str, Any] | None = None) -> str: + if not path_params: + return path + for name, value in path_params.items(): + path = path.replace("{" + name + "}", quote(str(value), safe="")) + return path + + @staticmethod + def _clean_mapping(values: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not values: + return None + cleaned = {key: value for key, value in values.items() if value is not None} + return cleaned or None + + @staticmethod + def _decode_response(response: requests.Response) -> Any: + if response.status_code == 204 or not response.content: + return None + content_type = response.headers.get("Content-Type", "") + if "json" in content_type.lower(): + return response.json() + try: + return response.json() + except ValueError: + return response.text + + # ---- Handwritten semantic helpers consumed by tangle-deploy ---------- + + def get_execution_details(self, execution_id: str) -> GetExecutionInfoResponse: + details = self.executions_details(execution_id) + self._enrich_execution_tree(details) + return details + + def stream_execution_container_log(self, execution_id: str) -> requests.Response: + response = self._make_request( + "GET", + self._format_path( + "/api/executions/{id}/stream_container_log", + {"id": execution_id}, + ), + stream=True, + ) + response.raise_for_status() + return response + + def get_component_spec(self, digest: str) -> ComponentSpec: + """Return a parsed domain component spec from the generated component endpoint.""" + + return ComponentSpec.from_dict(_to_plain(self.components_get(digest))) + + def resolve_digest(self, digest: str) -> str: + """Resolve a component digest/name, following deprecation successors.""" + + current = digest + seen: set[str] = set() + + while current not in seen: + seen.add(current) + matches = self._published_component_rows(include_deprecated=True, digest=current) + if not matches: + matches = self._published_component_rows( + include_deprecated=True, + name_substring=current, + ) + if len(matches) != 1: + return current + + component = matches[0] + resolved = str(component.get("digest") or current) + successor = component.get("superseded_by") + if component.get("deprecated") and successor: + current = str(successor) + continue + return resolved + + return current + + def _published_component_rows( + self, + include_deprecated: bool = False, + name_substring: str | None = None, + published_by_substring: str | None = None, + digest: str | None = None, + ) -> list[dict[str, Any]]: + data = _to_plain( + self.published_components_list( + include_deprecated=include_deprecated, + name_substring=name_substring, + published_by_substring=published_by_substring, + digest=digest, + ) + ) + if isinstance(data, dict): + return list(data.get("published_components") or []) + return list(data or []) + + def list_published_component_infos( + self, + include_deprecated: bool = False, + name_substring: str | None = None, + published_by_substring: str | None = None, + digest: str | None = None, + *, + fetch_specs: bool = False, + ) -> list[ComponentInfo]: + infos = [ + ComponentInfo.from_dict(component) + for component in self._published_component_rows( + include_deprecated=include_deprecated, + name_substring=name_substring, + published_by_substring=published_by_substring, + digest=digest, + ) + ] + if fetch_specs: + for info in infos: + if not info.digest: + continue + try: + info.component_spec = self.get_component_spec(info.digest) + except Exception as exc: # pragma: no cover - best-effort enrichment + info.spec_error = str(exc) + return infos + + def find_existing_components( + self, + components: Iterable[ComponentSpec | Mapping[str, Any] | str] | None = None, + *, + names: Iterable[str] | None = None, + digests: Iterable[str] | None = None, + include_deprecated: bool = False, + published_by: str | None = None, + published_by_substring: str | None = None, + verbose: bool = False, + ) -> list[ComponentInfo]: + """Find published components matching component specs, names, or digests. + + ``components`` may contain domain component specs, mapping-like component + references, or plain component names. Results are de-duplicated by digest + when available, falling back to name. + """ + + search_names = set(names or []) + search_digests = set(digests or []) + for component in components or []: + data = _to_plain(component) + if isinstance(component, str): + search_names.add(component) + elif isinstance(component, ComponentSpec): + search_names.update(name for name in component.search_names if name) + if component.digest: + search_digests.add(component.digest) + elif isinstance(data, Mapping): + if data.get("name"): + search_names.add(str(data["name"])) + if data.get("digest"): + search_digests.add(str(data["digest"])) + + publisher_filter = published_by_substring or published_by + found: dict[str, ComponentInfo] = {} + + def add(info: ComponentInfo) -> None: + key = info.digest or info.name + if not key: + return + found[key] = info + if verbose: + self.logger.info(f" Found existing component: {info.name} ({key[:16]}...)") + + for digest in search_digests: + for info in self.list_published_component_infos( + include_deprecated=include_deprecated, + published_by_substring=publisher_filter, + digest=digest, + ): + add(info) + for name in search_names: + for info in self.list_published_component_infos( + include_deprecated=include_deprecated, + published_by_substring=publisher_filter, + name_substring=name, + ): + if info.name.lower() == name.lower(): + add(info) + return list(found.values()) + + def get_run_details( + self, + run_id: str, + include_implementations: bool = False, + include_annotations: bool = False, + include_execution_state: bool = False, + execution_id: str | None = None, + ) -> RunDetails: + annotations_run_id: str | None = run_id + try: + run = PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) + root_execution_id = execution_id or run.root_execution_id + except requests.HTTPError as exc: + if exc.response is None or exc.response.status_code != 404 or execution_id is not None: + raise + root_execution_id = run_id + annotations_run_id = None + run = PipelineRun( + id=run_id, + root_execution_id=root_execution_id, + raw={"id": run_id, "root_execution_id": root_execution_id}, + ) + + execution = self.get_execution_details(root_execution_id) if root_execution_id else None + if execution and not include_implementations: + self._strip_execution_raw_tasks_for_run_details(execution) + execution.strip_implementations() + raw_annotations = ( + self.pipeline_runs_annotations(annotations_run_id) + if include_annotations and annotations_run_id + else None + ) + annotations = raw_annotations if isinstance(raw_annotations, dict) else None + execution_state = ( + GraphExecutionState.from_dict( + _to_plain(self.executions_graph_execution_state(root_execution_id)) + ) + if include_execution_state and root_execution_id + else None + ) + return RunDetails( + run=run, + execution=execution, + annotations=annotations, + execution_state=execution_state, + ) + + def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None: + try: + run = self.pipeline_runs_get(run_id) + root_execution_id = getattr(run, "root_execution_id", None) + if root_execution_id is None and isinstance(run, dict): + root_execution_id = run.get("root_execution_id") + except requests.HTTPError as exc: + if exc.response is None or exc.response.status_code != 404: + raise + root_execution_id = run_id + + if not root_execution_id: + return None + execution = self.executions_details(root_execution_id) + return execution.task_spec + + def _enrich_execution_tree(self, execution: GetExecutionInfoResponse) -> None: + child_ids = execution.raw.get("child_task_execution_ids") or {} + if not isinstance(child_ids, dict): + return + + raw_tasks = self._execution_graph_tasks(execution) + for task_name, child_execution_id in child_ids.items(): + if not child_execution_id: + continue + child = self.executions_details(child_execution_id) + self._enrich_execution_tree(child) + execution.child_executions[task_name] = child + + task = execution.task_spec.graph_tasks.get(task_name) + raw_task = raw_tasks.get(task_name) if isinstance(raw_tasks, dict) else None + if raw_task is None and task is not None: + raw_task = task.raw + + context = { + "execution_id": child.id, + "input_artifacts": child.input_artifacts, + "output_artifacts": child.output_artifacts, + } + if child.raw.get("state") is not None: + context["state"] = child.raw["state"] + + if task is not None: + task.raw.update(context) + if isinstance(raw_task, dict): + raw_task.update(context) + child_impl = ( + child.task_spec.component_spec.implementation + if child.task_spec.component_spec + else None + ) + raw_spec = raw_task.get("componentRef", {}).get("spec") + if isinstance(raw_spec, dict) and child_impl: + raw_spec["implementation"] = child_impl + + @staticmethod + def _execution_graph_tasks(execution: GetExecutionInfoResponse) -> dict[str, Any]: + implementation = ( + execution.task_spec.component_spec.implementation + if execution.task_spec.component_spec + else None + ) + if not isinstance(implementation, dict): + return {} + graph = implementation.get("graph") + if not isinstance(graph, dict): + return {} + tasks = graph.get("tasks") + return tasks if isinstance(tasks, dict) else {} + + def _strip_execution_raw_tasks_for_run_details( + self, + execution: GetExecutionInfoResponse, + ) -> None: + for raw_task in self._execution_graph_tasks(execution).values(): + if isinstance(raw_task, dict): + self._strip_raw_task_for_run_details(raw_task) + for child in execution.child_executions.values(): + self._strip_execution_raw_tasks_for_run_details(child) + + def _strip_raw_task_for_run_details(self, task: dict[str, Any]) -> None: + component_ref = task.get("componentRef") + if not isinstance(component_ref, dict): + return + component_ref.pop("text", None) + spec = component_ref.get("spec") + if not isinstance(spec, dict): + return + + annotations = spec.get("metadata", {}).get("annotations") + if isinstance(annotations, dict): + for key in ComponentSpec._STRIP_ANNOTATION_KEYS: + annotations.pop(key, None) + + implementation = spec.get("implementation") + if not isinstance(implementation, dict): + return + graph = implementation.get("graph") + if isinstance(graph, dict) and isinstance(graph.get("tasks"), dict): + for child_task in graph["tasks"].values(): + if isinstance(child_task, dict): + self._strip_raw_task_for_run_details(child_task) + else: + spec.pop("implementation", None) + + +def _to_plain(value: Any) -> Any: + if value is None: + return None + if hasattr(value, "to_dict") and callable(value.to_dict): + return value.to_dict() + if hasattr(value, "model_dump") and callable(value.model_dump): + return value.model_dump(by_alias=True) + if is_dataclass(value): + return asdict(value) + if isinstance(value, list): + return [_to_plain(item) for item in value] + if isinstance(value, tuple): + return tuple(_to_plain(item) for item in value) + if isinstance(value, dict): + return {key: _to_plain(item) for key, item in value.items()} + return value + + +__all__ = ["TangleApiClient"] diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py new file mode 100644 index 0000000..b24b52d --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -0,0 +1,1856 @@ +""" +Component YAML generator from Python functions. + +Converts Python functions into Tangle component YAML files. Supports two modes: + +- **inline** (default): Single-file components with source code embedded directly. +- **bundle**: Multi-file components with local dependency modules serialized via + zlib-compressed source text and injected into sys.modules at runtime. + +Key functions: +- generate_component_yaml() - Top-level entry point for YAML generation +- extract_interface() - Introspects a function's signature, types, and docstring +- extract_file_metadata() - Extracts metadata (name, version, etc.) from source via AST +- extract_docstring_metadata() - Parses the Metadata section from a docstring string +""" + +import ast +import importlib.util +import inspect +import json +import os +import re +import sys +import textwrap +import types +import typing +import warnings +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Literal + +import docstring_parser + +from tangle_cli.module_bundler import ModuleBundler +from tangle_cli.utils import dump_yaml, get_git_info, get_git_root + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + + +# ============================================================================ +# InputPath / OutputPath annotation types +# ============================================================================ +# These mirror the cloud_pipelines.components types so we can introspect +# functions that use them without requiring the cloud_pipelines SDK. + + +class InputPath: + """Annotation indicating a function parameter receives a file path for input data.""" + + def __init__(self, type: str | None = None): + self.type = type + + +class OutputPath: + """Annotation indicating a function parameter receives a file path for output data.""" + + def __init__(self, type: str | None = None): + self.type = type + + +# ============================================================================ +# Type mapping (replicating Cloud-Pipelines SDK _data_passing.py) +# ============================================================================ + +# Python type → Tangle type name +_TYPE_TO_TANGLE: dict[type, str] = { + str: "String", + int: "Integer", + float: "Float", + bool: "Boolean", + list: "JsonArray", + dict: "JsonObject", +} + +# Tangle type name → argparse deserializer expression +_TYPE_TO_DESERIALIZER: dict[str, str] = { + "String": "str", + "Integer": "int", + "Float": "float", + "Boolean": "_deserialize_bool", + "JsonArray": "json.loads", + "JsonObject": "json.loads", +} + +# Tangle type names that need extra definitions in the generated code +_TYPE_DEFINITIONS: dict[str, str] = { + "Boolean": textwrap.dedent("""\ + def _deserialize_bool(s): + s = s.lower() + if s in ("true", "1", "yes"): + return True + if s in ("false", "0", "no"): + return False + raise TypeError( + f'Error parsing "{s}" as bool value. Supported values: "true", "false", "1", "0".' + )"""), + "JsonArray": "import json", + "JsonObject": "import json", +} + +_MAKE_PARENT_DIRS_HELPER = textwrap.dedent("""\ + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path""") + +# Tangle type name → output serializer expression (for NamedTuple return fields) +_TYPE_TO_SERIALIZER: dict[str, str] = { + "String": "_serialize_str", + "Integer": "str", + "Float": "str", + "Boolean": "str", + "JsonArray": "json.dumps", + "JsonObject": "json.dumps", +} + +_SERIALIZE_STR_HELPER = textwrap.dedent("""\ + def _serialize_str(str_value) -> str: + if isinstance(str_value, str): + return str_value + else: + return str(str_value)""") + + +# ============================================================================ +# Data structures +# ============================================================================ + + +@dataclass +class ParamInfo: + """Describes a single function parameter mapped to a component input or output.""" + + name: str # Python parameter name + yaml_name: str # Name in YAML (may have _path/_file suffix stripped) + python_type: str | None # Original Python type annotation string + tangle_type: str | None # Tangle type: String, Integer, Float, etc. + kind: Literal["input", "output", "input_path", "return_output"] + description: str | None = None + default: Any = inspect.Parameter.empty + optional: bool = False + deserializer: str = "str" # argparse type= expression + + +@dataclass +class FunctionSpec: + """Complete specification of a function for component generation.""" + + name: str + component_name: str + description: str | None + params: list[ParamInfo] = field(default_factory=list) + return_params: list[ParamInfo] = field(default_factory=list) # Return value outputs + single_return_output: bool = False # True when -> str (not NamedTuple); needs _outputs=[_outputs] wrapping + source_code: str = "" + source_code_stripped: str = "" + module_source_stripped: str = "" # Full module source (for bundle mode) + docstring_metadata: dict[str, str] = field(default_factory=dict) # name, version, updated_at from Metadata: + + @property + def inputs(self) -> list[ParamInfo]: + return [p for p in self.params if p.kind in ("input", "input_path")] + + @property + def outputs(self) -> list[ParamInfo]: + """OutputPath parameter outputs.""" + return [p for p in self.params if p.kind == "output"] + + @property + def all_outputs(self) -> list[ParamInfo]: + """All outputs: OutputPath parameters + NamedTuple return fields.""" + return self.outputs + self.return_params + + +# ============================================================================ +# Module loading +# ============================================================================ + + +def _ensure_cloud_pipelines_shim() -> None: + """Register import-time shims used while introspecting authoring files. + + This allows loading Python files that use `from cloud_pipelines import components` + and/or TD authoring decorators without requiring those authoring packages. + The TD authoring constructs are stripped from generated runtime code later. + """ + if "cloud_pipelines" not in sys.modules: + components_mod = types.ModuleType("cloud_pipelines.components") + setattr(components_mod, "InputPath", InputPath) + setattr(components_mod, "OutputPath", OutputPath) + + cloud_pipelines_mod = types.ModuleType("cloud_pipelines") + setattr(cloud_pipelines_mod, "components", components_mod) + + sys.modules["cloud_pipelines"] = cloud_pipelines_mod + sys.modules["cloud_pipelines.components"] = components_mod + + _ensure_tangle_deploy_authoring_shim() + + +def _identity_decorator(*args, **kwargs): + def decorate(func): + return func + + return decorate + + +class _AuthoringGeneric: + def __class_getitem__(cls, item): + return cls + + def __init__(self, *args, **kwargs): + pass + + +def _ensure_tangle_deploy_authoring_shim() -> None: + """Register a tiny shim for TD pipeline authoring imports if absent.""" + if "tangle_deploy.python_pipeline" in sys.modules: + return + + tangle_deploy_mod = sys.modules.get("tangle_deploy") or types.ModuleType("tangle_deploy") + python_pipeline_mod = types.ModuleType("tangle_deploy.python_pipeline") + for name in ("task", "pipeline", "subpipeline", "registered"): + setattr(python_pipeline_mod, name, _identity_decorator) + for name in ("In", "Out", "Outputs", "TaskEnv"): + setattr(python_pipeline_mod, name, _AuthoringGeneric) + setattr(python_pipeline_mod, "ref", lambda *args, **kwargs: None) + + setattr(tangle_deploy_mod, "python_pipeline", python_pipeline_mod) + sys.modules.setdefault("tangle_deploy", tangle_deploy_mod) + sys.modules["tangle_deploy.python_pipeline"] = python_pipeline_mod + + +def load_python_module(file_path: Path, extra_sys_path: list[Path] | None = None) -> Any: + """Dynamically import a Python module from a file path. + + Args: + file_path: Path to the Python source file. + extra_sys_path: Additional directories to add to ``sys.path`` during + module loading. This is needed when the module imports sibling + packages that live outside ``file_path.parent`` (e.g. when + ``--resolve-root`` points at a parent ``src/`` directory). + """ + _ensure_cloud_pipelines_shim() + + module_name = file_path.stem + spec = importlib.util.spec_from_file_location(module_name, location=str(file_path)) + if not spec or not spec.loader: + raise ValueError(f"Unable to create module spec for {file_path}") + module = importlib.util.module_from_spec(spec) + # Add the module's directory to sys.path so relative imports work + module_dir = str(file_path.parent.resolve()) + original_path = sys.path.copy() + if module_dir not in sys.path: + sys.path.insert(0, module_dir) + # Add extra directories (e.g. resolve_root) so sibling imports resolve + if extra_sys_path: + for p in reversed(extra_sys_path): + p_str = str(p.resolve()) + if p_str not in sys.path: + sys.path.insert(0, p_str) + try: + spec.loader.exec_module(module) + finally: + sys.path = original_path + return module + + +def get_function_from_module(module: Any, function_name: str | None = None) -> Callable: + """Get a function from a loaded module. + + If function_name is specified, returns that function. + Otherwise, returns the single public function (errors if 0 or >1). + """ + if function_name: + func = getattr(module, function_name, None) + if func is None or not callable(func): + raise ValueError(f"Function '{function_name}' not found in module {module.__name__}") + return func + + functions = [ + getattr(module, name) + for name in dir(module) + if not name.startswith("_") and callable(getattr(module, name)) and not isinstance(getattr(module, name), type) + ] + + if not functions: + raise ValueError(f"No public functions found in module {module.__name__}") + if len(functions) > 1: + names = [f.__name__ for f in functions] + raise ValueError( + f"Found multiple functions in module {module.__name__}: {names}. " "Please specify --function-name." + ) + return functions[0] + + +# ============================================================================ +# Type annotation resolution +# ============================================================================ + + +def _resolve_annotation(annotation: Any) -> tuple[str | None, str, Literal["input", "output", "input_path"]]: + """Resolve a parameter annotation to (tangle_type, deserializer, kind). + + Returns: + (tangle_type, deserializer_code, kind) + """ + if annotation is inspect.Parameter.empty or annotation is None: + return "String", "str", "input" + + # Handle InputPath / OutputPath (both our local versions and cloud_pipelines versions) + type_name = type(annotation).__name__ + if type_name == "OutputPath": + inner_type = getattr(annotation, "type", None) or "String" + return inner_type, "_make_parent_dirs_and_return_path", "output" + if type_name == "InputPath": + inner_type = getattr(annotation, "type", None) or "String" + return inner_type, "str", "input_path" + + # Handle generic types first: Optional[T], list[T], dict[K,V], Union[T, None] + # Must come before isinstance(type) check because list[str] passes isinstance(type) in Python 3.10 + origin = typing.get_origin(annotation) + if origin in (list,): + return "JsonArray", "json.loads", "input" + if origin in (dict,): + return "JsonObject", "json.loads", "input" + if origin is typing.Union or origin is types.UnionType: + args = typing.get_args(annotation) + # Optional[T] == Union[T, None] + if len(args) == 2 and type(None) in args: + non_none = args[0] if args[1] is type(None) else args[1] + return _resolve_annotation(non_none) + return None, "str", "input" + + # Handle direct Python types (after generic check) + if isinstance(annotation, type): + tangle = _TYPE_TO_TANGLE.get(annotation) + if tangle: + return tangle, _TYPE_TO_DESERIALIZER[tangle], "input" + return str(annotation.__name__), "str", "input" + + # ForwardRef or other annotation — use string representation + return str(getattr(annotation, "__forward_arg__", annotation)), "str", "input" + + +def _make_return_param(name: str, annotation: type) -> ParamInfo: + """Create a ParamInfo for a return value output.""" + tangle_type = _TYPE_TO_TANGLE.get(annotation, "String") + return ParamInfo( + name=name, + yaml_name=name, + python_type=str(annotation) if annotation else None, + tangle_type=tangle_type, + kind="return_output", + description=None, + deserializer=_TYPE_TO_SERIALIZER.get(tangle_type, "_serialize_str"), + ) + + +def _resolve_namedtuple_return(return_ann: Any) -> list[ParamInfo]: + """Extract output parameters from a NamedTuple return annotation.""" + # __annotations__ doesn't exist in python 3.5 and earlier + # _field_types doesn't exist in python 3.9 and later + field_annotations = getattr(return_ann, "__annotations__", None) or getattr(return_ann, "_field_types", None) + return [ + _make_return_param( + name=field_name, + annotation=field_annotations.get(field_name, str) if field_annotations else str, + ) + for field_name in return_ann._fields + ] + + +def _resolve_single_return(return_ann: type) -> ParamInfo | None: + """Create an output parameter for a single (non-NamedTuple) return type. + + Returns None if the type is not a recognized Tangle type. + """ + if return_ann not in _TYPE_TO_TANGLE: + return None + return _make_return_param(name="Output", annotation=return_ann) + + +def _resolve_return_type(func: Callable) -> tuple[list[ParamInfo], bool]: + """Extract output parameters from the function's return type annotation. + + Matches the Cloud-Pipelines SDK behavior: + - NamedTuple return -> one output per field (multi-output) + - Single type return (str, int, etc.) -> one output named "Output" (single-output) + - No return annotation -> no outputs + + Returns: + (return_params, single_return_output) where single_return_output is True + when the return is a plain type (not NamedTuple) and the generated code + needs ``_outputs = [_outputs]`` wrapping. + """ + # Use inspect.signature like the SDK does (avoids typing.get_type_hints issues + # with InputPath/OutputPath instances that aren't valid types for Optional[]). + return_ann = inspect.signature(func).return_annotation + if return_ann is None or return_ann is inspect.Parameter.empty: + return [], False + + if hasattr(return_ann, "_fields"): + return _resolve_namedtuple_return(return_ann), False + + param = _resolve_single_return(return_ann) + if param: + return [param], True + + return [], False + + +# ============================================================================ +# Interface extraction +# ============================================================================ + + +def _python_name_to_component_name(name: str) -> str: + """Convert a Python function name to a human-readable component name.""" + name_with_spaces = re.sub(" +", " ", name.replace("_", " ")).strip() + if not name_with_spaces: + return name + return name_with_spaces[0].upper() + name_with_spaces[1:] + + +def extract_docstring_metadata(docstring: str) -> dict[str, str]: + """Extract metadata and description from a docstring. + + Extracts the main description text (before any sections) and key-value pairs + from the Metadata section: + + Processes and validates input data. + + Metadata: + name: My Component Name + version: 1.2 + updated_at: 2025-01-01T00:00:00Z + + Args: + ... + + Returns: + Dict with keys like "description", "name", "version", "updated_at" (only present if found). + """ + sections = [ + "args", + "arguments", + "parameters", + "returns", + "raises", + "yields", + "note", + "notes", + "example", + "examples", + "metadata", + ] + + metadata: dict[str, str] = {} + in_metadata = False + in_description = True + description_lines: list[str] = [] + + for line in docstring.split("\n"): + stripped = line.strip() + + # Check for section headers + if stripped and stripped.rstrip(":").lower() in sections: + in_description = False + if stripped.lower() == "metadata:": + in_metadata = True + elif in_metadata: + break + continue + + if in_metadata: + # Parse any key: value pair + kv_match = re.match(r"^(\w[\w_]*)\s*:\s*(.+)", stripped) + if kv_match: + key = kv_match.group(1).lower() + value = kv_match.group(2).strip() + # Normalize version_timestamp to updated_at + if key == "version_timestamp": + key = "updated_at" + metadata[key] = value + elif in_description: + # Collect description lines (before any section) + if stripped: + description_lines.append(stripped) + + if description_lines: + metadata["description"] = " ".join(description_lines) + + return metadata + + +def find_function_in_source( + file_path: Path, function_name: str | None = None +) -> tuple[str | None, ast.FunctionDef | None]: + """Find a function in a Python source file by AST parsing. + + Args: + file_path: Path to the Python file + function_name: Name of function to find. If not found or not provided, + falls back to first public function in the file. + + Returns: + Tuple of (function_name, function_node) or (None, None) if no functions found. + """ + try: + content = file_path.read_text() + tree = ast.parse(content) + + all_functions = [ + node + for node in ast.iter_child_nodes(tree) + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_") + ] + + if not all_functions: + return None, None + + if function_name: + for func in all_functions: + if func.name == function_name: + return func.name, func + # Function not found, fall back to first function + first_func = all_functions[0] + warnings.warn( + f"Function '{function_name}' not found in {file_path.name}, " f"using '{first_func.name}' instead" + ) + return first_func.name, first_func + + first_func = all_functions[0] + return first_func.name, first_func + + except (SyntaxError, ValueError, OSError) as e: + warnings.warn(f"Could not parse {file_path}: {e}") + return None, None + + +def extract_file_metadata(file_path: Path, function_name: str | None = None) -> tuple[dict[str, str], str | None]: + """Extract metadata from a function's docstring in a Python source file. + + Finds the function via AST, extracts its docstring, and parses the Metadata + section for keys like name, version, updated_at, plus the description. + + Args: + file_path: Path to the Python file + function_name: Function to extract from. Defaults to file stem. + + Returns: + Tuple of (metadata_dict, actual_function_name_used) + """ + if not function_name: + function_name = file_path.stem.replace("-", "_") + + actual_func_name, func_node = find_function_in_source(file_path, function_name) + if not func_node: + return {}, None + + docstring = ast.get_docstring(func_node) + if docstring: + return extract_docstring_metadata(docstring), actual_func_name + + return {}, actual_func_name + + +def extract_interface( + func: Callable, + docstring_metadata: dict[str, str], +) -> FunctionSpec: + """Extract component interface from a Python function. + + Uses inspect.signature() for parameter info and docstring_parser for descriptions. + + Args: + func: The Python function to introspect. + docstring_metadata: Metadata from extract_file_metadata or extract_docstring_metadata. + """ + signature = inspect.signature(func) + parsed_docstring = docstring_parser.parse(inspect.getdoc(func) or "") + doc_dict = {p.arg_name: p.description for p in parsed_docstring.params} + + params: list[ParamInfo] = [] + + for param in signature.parameters.values(): + annotation = param.annotation + tangle_type, deserializer, kind = _resolve_annotation(annotation) + + # Determine the YAML name (strip _path/_file suffixes for InputPath/OutputPath) + yaml_name = param.name + if kind in ("output", "input_path"): + if yaml_name.endswith("_path"): + yaml_name = yaml_name[: -len("_path")] + elif yaml_name.endswith("_file"): + yaml_name = yaml_name[: -len("_file")] + + # Determine optionality and default + optional = False + default = inspect.Parameter.empty + if param.default is not inspect.Parameter.empty: + if kind == "input": + optional = True + default = param.default + elif kind == "input_path" and param.default is None: + optional = True + + params.append( + ParamInfo( + name=param.name, + yaml_name=yaml_name, + python_type=str(annotation) if annotation is not inspect.Parameter.empty else None, + tangle_type=tangle_type, + kind=kind, + description=doc_dict.get(param.name), + default=default, + optional=optional, + deserializer=deserializer, + ) + ) + + component_name = docstring_metadata.get("name") or _python_name_to_component_name(func.__name__) + description = parsed_docstring.description + if description: + # Strip Metadata: section that docstring_parser doesn't understand + desc_lines = [] + for line in description.split("\n"): + if line.strip().lower() == "metadata:": + break + desc_lines.append(line) + description = "\n".join(desc_lines).strip() + + # Get source code + source_code = "" + source_code_stripped = "" + module_source_stripped = "" + try: + raw_source = inspect.getsource(func) + source_code = textwrap.dedent(raw_source) + # Remove decorators + lines = source_code.split("\n") + while lines and not lines[0].startswith("def "): + del lines[0] + source_code = "\n".join(lines) + source_code_stripped = _strip_type_hints(source_code) + + # module_source_stripped is populated externally via generate_component_yaml + # (since we have the file path there but not here) + except (OSError, TypeError) as e: + warnings.warn(f"Could not get source code for {func.__name__}: {e}") + + # Extract return type outputs (NamedTuple or single value) + return_params, single_return_output = _resolve_return_type(func) + + # Enrich return_params with descriptions from docstring Returns section. + # docstring_parser interprets "field_name: description" under Returns as + # type_name=field_name, so we check both return_name and type_name. + if return_params and parsed_docstring.many_returns: + returns_dict: dict[str, str] = {} + for r in parsed_docstring.many_returns: + name = r.return_name or r.type_name + if name and r.description: + returns_dict[name] = r.description + for rp in return_params: + if rp.name in returns_dict: + rp.description = returns_dict[rp.name] + + return FunctionSpec( + name=func.__name__, + component_name=component_name, + description=description, + params=params, + return_params=return_params, + single_return_output=single_return_output, + source_code=source_code, + source_code_stripped=source_code_stripped, + module_source_stripped=module_source_stripped, + docstring_metadata=docstring_metadata, + ) + + +# ============================================================================ +# __main__ guard stripping +# ============================================================================ + + +def _strip_main_guard(source_code: str) -> str: + """Remove ``if __name__ == "__main__":`` blocks from source code. + + These guards conflict with the generated argparse wrapper because both + execute at module level. When the guard appears *before* the wrapper it + fires first and typically calls ``sys.exit()``, preventing the component + from running. + """ + try: + tree = ast.parse(source_code) + except SyntaxError: + return source_code + + lines = source_code.splitlines(keepends=True) + + # Collect line ranges to remove (1-indexed, inclusive) + ranges_to_remove: list[tuple[int, int]] = [] + for node in ast.iter_child_nodes(tree): + if not isinstance(node, ast.If): + continue + if _is_name_main_test(node.test): + start = node.lineno + end = node.end_lineno or node.lineno + ranges_to_remove.append((start, end)) + + if not ranges_to_remove: + return source_code + + removed: set[int] = set() + for start, end in ranges_to_remove: + removed.update(range(start, end + 1)) + + kept = [line for i, line in enumerate(lines, 1) if i not in removed] + return "".join(kept) + + +def _is_name_main_test(node: ast.expr) -> bool: + """Return True if *node* is ``__name__ == "__main__"`` (in either order).""" + if not isinstance(node, ast.Compare): + return False + if len(node.ops) != 1 or not isinstance(node.ops[0], ast.Eq): + return False + if len(node.comparators) != 1: + return False + + left = node.left + right = node.comparators[0] + + def _is_dunder_name(n: ast.expr) -> bool: + return isinstance(n, ast.Name) and n.id == "__name__" + + def _is_main_str(n: ast.expr) -> bool: + return isinstance(n, ast.Constant) and n.value == "__main__" + + return (_is_dunder_name(left) and _is_main_str(right)) or (_is_main_str(left) and _is_dunder_name(right)) + + +# ============================================================================ +# Authoring-construct stripping (authoring imports + @task/@pipeline/@subpipeline/@registered) +# ============================================================================ + +# Decorators that exist purely to *record* a function at authoring time. They +# must never survive into the baked operation program (see +# _strip_authoring_constructs). ``registered`` marks an op published separately +# via its own gen_config.yaml; when that same op is baked (through its +# local_from_python entry) the decorator + its authoring import must be stripped +# too, exactly like @task. +_AUTHORING_DECORATOR_NAMES = frozenset({"task", "pipeline", "subpipeline", "registered"}) + +# The python-pipeline authoring module. ONLY imports of this module (and its +# submodules) are authoring-only and stripped from the baked source. We +# deliberately do NOT strip other ``tangle_deploy.*`` packages (e.g. +# ``tangle_deploy.utils``): those may be legitimate runtime helpers used inside a +# ``@task`` body, and dropping them would raise ``NameError`` in the operation +# container. +_AUTHORING_IMPORT_MODULE = "tangle_deploy.python_pipeline" + +# The authoring-only ``TaskEnv`` class name. A module-level ``X = TaskEnv(...)`` +# (or ``X = .TaskEnv(...)``) declaration is authoring-only by contract and +# is stripped from the baked source by ``_strip_authoring_constructs``. +# Matched by trailing NAME only (like the authoring decorators), because in +# python-pipeline authoring files ``TaskEnv`` always +# resolves to ``tangle_deploy.python_pipeline.TaskEnv``. +_AUTHORING_ENV_CLASS_NAME = "TaskEnv" + + +class AuthoringStripError(ValueError): + """Raised when env-only authoring code cannot be safely stripped. + + The TaskEnv runtime-strip hardening (``_strip_authoring_constructs``) + raises this when a ``@task(env=...)`` env binding is entangled with + runtime code — e.g. a mixed ``from _envs import UPI, helper`` import whose + ``helper`` is used at runtime, or a collected env name referenced by the + kept task body. Failing fast here is intentional: silently baking a broken + ``from _envs import UPI`` / ``UPI = TaskEnv(...)`` would only surface as a + ``NameError`` / ``ImportError`` at container start. The message tells the + author how to split the import or keep TaskEnv values authoring-only. + """ + + +def _decorator_called_name(node: ast.expr) -> str | None: + """Return the trailing name a decorator expression resolves to. + + Handles ``@name`` / ``@name(...)`` and ``@mod.name`` / ``@mod.name(...)`` + forms, returning the trailing attribute/name (e.g. ``task`` for both + ``@task(...)`` and ``@tangle_deploy.python_pipeline.task(...)``). Returns + ``None`` for shapes we do not recognise so callers leave them untouched. + + Limitation (v1, intentional): matching is by trailing NAME only, not by + import resolution. A hypothetical unrelated ``@some_other_lib.task(...)`` + decorator would therefore also match. This is acceptable because in + python-pipeline authoring files the only decorators named ``task`` / + ``pipeline`` / ``subpipeline`` are the authoring decorators; resolving the + import binding is deferred unless a real collision appears. + """ + if isinstance(node, ast.Call): + node = node.func + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _is_authoring_import(node: ast.stmt) -> bool: + """Return True if *node* imports the python-pipeline authoring surface. + + Matches ONLY the ``tangle_deploy.python_pipeline`` module (and its + submodules): + + - ``from tangle_deploy.python_pipeline import ...`` (including the aliased + ``from tangle_deploy.python_pipeline import ref as operation_by_ref`` form + and submodules like ``from tangle_deploy.python_pipeline.x import y``); + - ``import tangle_deploy.python_pipeline`` / ``import + tangle_deploy.python_pipeline as tp``. + + It does NOT match other ``tangle_deploy.*`` packages (e.g. + ``from tangle_deploy.utils import X``) — those can be genuine runtime helpers + referenced inside a ``@task`` body and must survive into the baked program. + Relative imports (``from . import x``) are never authoring imports. + """ + if isinstance(node, ast.ImportFrom): + if node.level: # relative import — not the authoring package + return False + module = node.module or "" + return module == _AUTHORING_IMPORT_MODULE or module.startswith(_AUTHORING_IMPORT_MODULE + ".") + if isinstance(node, ast.Import): + return any( + alias.name == _AUTHORING_IMPORT_MODULE or alias.name.startswith(_AUTHORING_IMPORT_MODULE + ".") + for alias in node.names + ) + return False + + +def _attr_root_name(node: ast.expr) -> str | None: + """Return the root ``Name`` id of an attribute chain (``a.b.c`` -> ``a``). + + Returns ``None`` for shapes that don't bottom out in a plain ``Name`` + (e.g. ``foo().bar``), so callers leave them untouched. + """ + while isinstance(node, ast.Attribute): + node = node.value + return node.id if isinstance(node, ast.Name) else None + + +def _env_keyword_binding_name(call: ast.Call) -> str | None: + """Return the module-level authoring name a ``@task(env=...)`` keyword needs. + + Inspects the ``env=`` keyword of a (stripped) ``@task(...)`` decorator and + returns the name of the module-level binding that must also be stripped so + the baked runtime program does not crash referencing an authoring-only name: + + - ``env=UPI`` -> ``"UPI"`` (a module-level env *binding* to strip, either an + ``UPI = TaskEnv(...)`` assignment or a ``from _envs import UPI`` import); + - ``env=_envs.UPI`` -> ``"_envs"`` (the module-alias root, so the + ``import _envs`` line can be stripped); + - ``env=TaskEnv(...)`` / ``env=tp.TaskEnv(...)`` (inline) -> ``None``: the + whole decorator line range is already deleted, so there is no residual + module-level binding to strip; + - anything else -> ``None`` (leave it untouched). + """ + for keyword in call.keywords: + if keyword.arg != "env": + continue + value = keyword.value + if isinstance(value, ast.Name): + return value.id + if isinstance(value, ast.Attribute): + return _attr_root_name(value) + # env=TaskEnv(...) / env=tp.TaskEnv(...) inline, or any other shape: + # the decorator range already covers it, no residual binding. + return None + return None + + +def _is_task_env_construction(value: ast.expr | None) -> bool: + """True if *value* is a direct ``TaskEnv(...)`` / ``.TaskEnv(...)`` call. + + Matched by trailing call name (mirroring ``_decorator_called_name``), so + both ``TaskEnv(image=...)`` and ``tp.TaskEnv(image=...)`` qualify. Used to + detect module-level env declarations like ``UPI = TaskEnv(...)`` regardless + of whether a ``@task(env=UPI)`` references them. + """ + return isinstance(value, ast.Call) and _decorator_called_name(value) == _AUTHORING_ENV_CLASS_NAME + + +def _import_bound_names(node: ast.Import | ast.ImportFrom) -> dict[str, ast.alias]: + """Map each name a top-level import binds into the namespace to its alias. + + - ``from m import UPI`` -> ``{"UPI": alias}`` + - ``from m import UPI as U`` -> ``{"U": alias}`` + - ``import _envs`` -> ``{"_envs": alias}`` (root of a dotted module path) + - ``import a.b.c`` -> ``{"a": alias}`` + - ``import envs as task_envs`` -> ``{"task_envs": alias}`` + """ + bound: dict[str, ast.alias] = {} + for alias in node.names: + if alias.asname: + bound[alias.asname] = alias + elif isinstance(node, ast.Import): + # ``import a.b.c`` binds only the top-level package ``a``. + bound[alias.name.split(".", 1)[0]] = alias + else: + bound[alias.name] = alias + return bound + + +def _annotation_name_node_ids(tree: ast.AST) -> set[int]: + """Return ``id()`` of every ``ast.Name`` that lives inside a type-annotation slot. + + Annotation slots are stripped from the baked output by ``_strip_type_hints`` + (which runs AFTER ``_strip_authoring_constructs``), so a name that appears + ONLY in an annotation is NOT a live runtime reference. Excluding these from + the fail-fast reference scan prevents a false positive where an env name + used only as a parameter/return type annotation (``def f(x: UPI) -> UPI:``) + is mistaken for a kept runtime reference (FIX N1, §3.5). + + Annotation slots covered (matching ``_strip_type_hints_ast``): + + - function parameter annotations: ``args.args`` / ``posonlyargs`` / + ``kwonlyargs`` plus ``*args`` (``vararg``) and ``**kwargs`` (``kwarg``); + - ``FunctionDef`` / ``AsyncFunctionDef`` return annotations (``-> T``); + - ``AnnAssign`` annotations (``x: T`` / ``x: T = ...``). + + Because ``tree`` stays alive for the duration of the caller, every node's + ``id()`` is stable and unique, so identity membership is reliable. + """ + annotation_slots: list[ast.expr] = [] + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + args = node.args + for arg in ( + *args.posonlyargs, + *args.args, + *args.kwonlyargs, + args.vararg, + args.kwarg, + ): + if arg is not None and arg.annotation is not None: + annotation_slots.append(arg.annotation) + if node.returns is not None: + annotation_slots.append(node.returns) + elif isinstance(node, ast.AnnAssign): + annotation_slots.append(node.annotation) + + name_ids: set[int] = set() + for slot in annotation_slots: + for sub in ast.walk(slot): + if isinstance(sub, ast.Name): + name_ids.add(id(sub)) + return name_ids + + +def _strip_authoring_constructs(source_code: str) -> str: + """Strip python-pipeline authoring imports and decorators from baked source. + + The generated operation container re-executes ``module_source_stripped`` at + startup and then calls the target function directly. Authoring constructs + must NOT survive into that runtime program: + + - re-running an ``@task`` / ``@pipeline`` / ``@subpipeline`` decorator + replaces the function with a ``CallableRef`` recorder, which raises at + call time because there is no active ``@pipeline`` trace context; + - on a thin image the ``from tangle_deploy.python_pipeline import ...`` + import itself can fail with ``ImportError``. + + This removes them via surgical AST line-range deletion (mirroring + ``_strip_main_guard``), so comments/formatting in the rest of the source + survive — we deliberately avoid a full ``ast.unparse`` round-trip. + + Contract this relies on: authoring-surface names (``task``, ``pipeline``, + ``subpipeline``, ``In``, ``Out``, ``Outputs``, ``ref``, ...) appear ONLY in + decorators and type annotations — both stripped before the source is baked — + never in a runtime function body. Dropping the whole authoring import line is + therefore safe. + + Scope of the strip (intentional v1 boundaries): + + - imports: only ``tangle_deploy.python_pipeline`` (and submodules) are + dropped — see ``_is_authoring_import``. Other ``tangle_deploy.*`` runtime + helpers are preserved. + - decorators: matched by trailing NAME (``task`` / ``pipeline`` / + ``subpipeline``), not by import resolution — see ``_decorator_called_name`` + for the limitation. Unrelated decorators (``@functools.cache``, + ``@property``, ...) are preserved. + + TaskEnv authoring-strip hardening (``@task(env=...)``): an env + declaration that exists ONLY to feed a stripped ``@task(env=...)`` decorator + would otherwise crash the baked program (``NameError: TaskEnv`` for a + co-located ``UPI = TaskEnv(...)`` whose import was stripped, or + ``ImportError`` for a ``from _envs import UPI`` whose module is not in the + runtime image). On top of the import/decorator strip this also removes, by + line range: + + - every module-level ``X = TaskEnv(...)`` / ``X: TaskEnv = TaskEnv(...)`` + declaration (direct ``TaskEnv(...)`` construction), and + - module-level bindings (assignment OR import) of any name a stripped + ``@task(env=...)`` referenced — ``env=UPI`` collects ``UPI`` + (``UPI = TaskEnv(...)`` / ``UPI = make_task_env(...)`` / ``from _envs import + UPI``); ``env=_envs.UPI`` collects the module alias ``_envs`` + (``import _envs``). + + It is deliberately narrow: only names PROVEN to participate in a stripped + ``@task(env=...)`` decorator or a direct module-level ``TaskEnv(...)`` call + are removed. It is NOT a general unused-import cleaner. It raises + :class:`AuthoringStripError` (fail-fast) rather than bake a broken program + when an env binding is entangled with runtime code: a mixed + ``from _envs import UPI, helper`` whose ``helper`` is used at runtime, or a + collected env name still referenced by the kept task body. + + This intentionally operates on ``module_source_stripped`` ONLY. It must never + touch the verbatim ``python_original_code`` annotation, which is read + directly from the source file elsewhere and kept byte-verbatim. + """ + try: + tree = ast.parse(source_code) + except SyntaxError: + return source_code + + lines = source_code.splitlines(keepends=True) + removed: set[int] = set() # 1-indexed line numbers to drop + # Names introduced ONLY to feed a stripped ``@task(env=...)`` decorator. + # Collected from ``env=`` keywords; used below to strip the matching + # module-level assignment/import binding. + collected_env_names: set[str] = set() + + for node in ast.walk(tree): + # Authoring imports — delete the whole (possibly multi-line) statement. + if isinstance(node, (ast.Import, ast.ImportFrom)) and _is_authoring_import(node): + start = node.lineno + end = node.end_lineno or node.lineno + removed.update(range(start, end + 1)) + continue + + # @task / @pipeline / @subpipeline decorators on functions/classes. + # The "@" shares the decorator expression's first line, so removing the + # node's full line range removes the "@" too. Real-world decorators span + # multiple lines, hence lineno..end_lineno rather than a prefix match. + decorator_list = getattr(node, "decorator_list", None) + if not decorator_list: + continue + for decorator in decorator_list: + if _decorator_called_name(decorator) in _AUTHORING_DECORATOR_NAMES: + start = decorator.lineno + end = decorator.end_lineno or decorator.lineno + removed.update(range(start, end + 1)) + # Record the env-only authoring name this @task(env=...) needs + # stripped from module scope (None for inline TaskEnv(...)). + if isinstance(decorator, ast.Call): + env_name = _env_keyword_binding_name(decorator) + if env_name is not None: + collected_env_names.add(env_name) + + # --- Fail-fast: nested/conditional env imports cannot be stripped (N1/N2) - + # + # Module-level removal below only touches ``tree.body``. An env import + # nested inside an ``if`` / ``try`` / function body (i.e. NOT a direct child + # of ``tree.body``) is therefore NOT stripped and would LEAK into the baked + # program -> ``ImportError`` on a thin runtime image (or re-binding an + # authoring-only name) at container start. We also must NOT line-delete a + # nested import: removing the only statement in a block leaves an empty + # suite -> ``IndentationError``. Converting the silent leak into a loud, + # actionable error is the correct, safe behavior (FIX N2, §3.5). + if collected_env_names: + top_level_stmt_ids = {id(stmt) for stmt in tree.body} + for node in ast.walk(tree): + if not isinstance(node, (ast.Import, ast.ImportFrom)): + continue + if id(node) in top_level_stmt_ids: + continue # module-level imports are handled by the strip below + nested_env = sorted(collected_env_names & _import_bound_names(node).keys()) + if nested_env: + names_repr = ", ".join(repr(n) for n in nested_env) + raise AuthoringStripError( + f"env name {names_repr} is imported inside a nested block " + "(if/try/function); TaskEnv env imports must be module-level " + "/ authoring-only. A nested env import is not stripped and " + "would leak into the baked runtime program (ImportError at " + "container start). Move it to a top-level import so it can be " + "stripped, and keep TaskEnv values authoring-only." + ) + + # --- TaskEnv env-only declarations / imports (§3.5) --------------------- + # + # Restricted to module-level statements (``tree.body``) so nested code is + # never touched. Two kinds of statement are stripped: + # 1. assignments that construct a TaskEnv directly (``X = TaskEnv(...)``) + # or whose target is a collected env name (``UPI = make_task_env(...)`` + # when ``@task(env=UPI)`` was seen), and + # 2. imports that bind a collected env name/module (``from _envs import + # UPI`` / ``import _envs``) when that name is env-only. + # + # We record each candidate's bound name(s) + line range, then verify (after + # a reference scan) that removing it cannot break kept runtime code. + env_assign_bindings: list[tuple[set[str], int, int]] = [] # (names, start, end) + env_import_candidates: list[tuple[ast.Import | ast.ImportFrom, int, int]] = [] + for stmt in tree.body: + if isinstance(stmt, ast.Assign): + simple_targets = {t.id for t in stmt.targets if isinstance(t, ast.Name)} + if _is_task_env_construction(stmt.value) or (simple_targets & collected_env_names): + env_assign_bindings.append((simple_targets, stmt.lineno, stmt.end_lineno or stmt.lineno)) + elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + tname = stmt.target.id + if _is_task_env_construction(stmt.value) or tname in collected_env_names: + env_assign_bindings.append(({tname}, stmt.lineno, stmt.end_lineno or stmt.lineno)) + elif isinstance(stmt, (ast.Import, ast.ImportFrom)): + if _is_authoring_import(stmt): + continue # already removed above + bound = _import_bound_names(stmt) + if collected_env_names & bound.keys(): + env_import_candidates.append((stmt, stmt.lineno, stmt.end_lineno or stmt.lineno)) + + # Provisionally drop every env declaration/import candidate. Their own line + # ranges hold no runtime ``Load`` of the bound name (assignment targets are + # ``Store``; import bindings are aliases), so including them now does not + # mask a real runtime reference detected below. + for _names, start, end in env_assign_bindings: + removed.update(range(start, end + 1)) + for _stmt, start, end in env_import_candidates: + removed.update(range(start, end + 1)) + + # Reference scan: every ``Name`` used in a ``Load`` context, mapped to the + # 1-indexed lines it appears on. Attribute roots (``_envs`` in + # ``_envs.UPI``) are plain ``Name`` Load nodes too, so this covers them. + # + # FIX N1 (§3.5): exclude ``Name`` nodes that live in a type-annotation slot + # (param/return/AnnAssign). Annotations are stripped from the baked output by + # ``_strip_type_hints`` (which runs later), so an env name used ONLY as a + # type annotation (``def f(x: UPI) -> UPI:``) is NOT a live runtime + # reference and must not trip the body-ref fail-fast. A real body reference + # (outside annotations) still records a Load and still fails fast. + if env_assign_bindings or env_import_candidates: + annotation_name_ids = _annotation_name_node_ids(tree) + load_lines: dict[str, set[int]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and id(node) not in annotation_name_ids: + load_lines.setdefault(node.id, set()).add(node.lineno) + + def _referenced_in_kept(name: str) -> bool: + # ``name`` is used by runtime code iff it has a ``Load`` on a line + # that survives the strip (i.e. not in ``removed``). + return any(line not in removed for line in load_lines.get(name, ())) + + # Fail fast: a stripped env declaration whose target the kept body still + # references would leave a dangling ``NameError`` — env names are + # authoring-only by contract. + for names, _start, _end in env_assign_bindings: + for name in names: + if _referenced_in_kept(name): + raise AuthoringStripError( + f"TaskEnv authoring name {name!r} is referenced by the " + "baked runtime code, but its declaration is stripped " + "because it is a @task(env=...) environment. TaskEnv " + "values are authoring-only: do not reference them from " + "a task body or other runtime code. Move the runtime " + "use out, or keep the value as a plain runtime object " + "that is not used as @task(env=...)." + ) + + for stmt, _start, _end in env_import_candidates: + bound = _import_bound_names(stmt) + env_bound = collected_env_names & bound.keys() + other_bound = bound.keys() - env_bound + # (a) Mixed import: an env-only name shares the statement with a + # runtime name that is actually used. We cannot line-delete just + # part of the statement, so fail fast with split guidance. + used_others = sorted(n for n in other_bound if _referenced_in_kept(n)) + if used_others: + raise AuthoringStripError( + "Import " + ", ".join(sorted(env_bound)) + " is a @task(env=...) environment but shares an import " + "statement with runtime name(s) " + + ", ".join(used_others) + + ". Split the import so TaskEnv env names are imported on " + "their own line (e.g. `from _envs import UPI` separate from " + "`from _envs import helper`); env imports are authoring-only " + "and stripped from the baked runtime program." + ) + # (b) The env name itself is still referenced by kept runtime code. + for name in sorted(env_bound): + if _referenced_in_kept(name): + raise AuthoringStripError( + f"TaskEnv authoring name {name!r} is imported and " + "referenced by the baked runtime code, but its import is " + "stripped because it is a @task(env=...) environment. " + "TaskEnv values are authoring-only: do not reference " + "them from a task body or other runtime code." + ) + + if not removed: + return source_code + + kept = [line for i, line in enumerate(lines, 1) if i not in removed] + return "".join(kept) + + +# ============================================================================ +# Type hint stripping (replicating SDK strip_type_hints) +# ============================================================================ + + +def _strip_type_hints(source_code: str) -> str: + """Strip type annotations from function definitions using the ast module.""" + try: + return _strip_type_hints_ast(source_code) + except Exception as e: + warnings.warn(f"Failed to strip type hints (using source as-is): {e}") + return source_code + + +def _byte_col_to_char_col(line: str, byte_col: int) -> int: + """Convert a UTF-8 byte offset to a Python string character index. + + AST col_offset/end_col_offset are UTF-8 byte offsets, not character indices. + For ASCII-only lines they're identical, but non-ASCII characters (e.g. "café") + cause the two to diverge. + """ + return len(line.encode("utf-8")[:byte_col].decode("utf-8", errors="replace")) + + +def _strip_type_hints_ast(source_code: str) -> str: + """Strip type annotations from function definitions using the ast module. + + Removes parameter annotations (`: type`) and return annotations (`-> type`) + from all function definitions. Uses AST to locate annotations, then performs + surgical string removal to preserve original formatting. + """ + tree = ast.parse(source_code) + lines = source_code.splitlines(keepends=True) + + # Collect (line, col_start, col_end) ranges to remove, in source order. + # We'll process them in reverse order so removals don't shift earlier offsets. + # All columns here are character indices (converted from AST byte offsets). + removals: list[tuple[int, int, int, int]] = [] # (start_line, start_col, end_line, end_col) + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + # --- Return annotation: remove " -> " before the colon --- + if node.returns is not None: + ret = node.returns + ret_start_line = ret.lineno # 1-indexed + ret_line_text = lines[ret_start_line - 1] + ret_start_col = _byte_col_to_char_col(ret_line_text, ret.col_offset) + ret_end_line = ret.end_lineno or ret_start_line + ret_end_line_text = lines[ret_end_line - 1] + ret_end_col = _byte_col_to_char_col(ret_end_line_text, ret.end_col_offset or (ret.col_offset + 1)) + + # Find the "->" token by scanning backwards from the annotation start. + # The arrow may be on the same line as the type, or on a preceding line + # (e.g. `def f()\n -> str:`), so we search backwards through lines. + # Bound the search to the def line to avoid matching a previous function. + min_line_idx = node.lineno - 1 # 0-indexed; the "def" line + arrow_line_idx = ret_start_line - 1 # 0-indexed + arrow_pos = -1 + while arrow_line_idx >= min_line_idx: + search_region = lines[arrow_line_idx] + if arrow_line_idx == ret_start_line - 1: + search_region = search_region[:ret_start_col] + arrow_pos = search_region.rfind("->") + if arrow_pos != -1: + break + arrow_line_idx -= 1 + + if arrow_pos != -1: + # Strip any whitespace before the arrow too + strip_start = arrow_pos + line_text = lines[arrow_line_idx] + while strip_start > 0 and line_text[strip_start - 1] == " ": + strip_start -= 1 + removals.append((arrow_line_idx + 1, strip_start, ret_end_line, ret_end_col)) + + # --- Parameter annotations: remove ": " from each arg --- + for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs: + if arg.annotation is None: + continue + ann = arg.annotation + # The annotation text starts after "param_name" with ": " + # arg node: name at (arg.lineno, arg.col_offset), length = len(arg.arg) + arg_line_text = lines[arg.lineno - 1] + name_end_col = _byte_col_to_char_col(arg_line_text, arg.col_offset) + len(arg.arg) + ann_end_line = ann.end_lineno or ann.lineno + ann_end_line_text = lines[ann_end_line - 1] + ann_end_col = _byte_col_to_char_col(ann_end_line_text, ann.end_col_offset or (ann.col_offset + 1)) + removals.append((arg.lineno, name_end_col, ann_end_line, ann_end_col)) + + # vararg (*args) and kwarg (**kwargs) + for maybe_arg in (node.args.vararg, node.args.kwarg): + if maybe_arg is not None and maybe_arg.annotation is not None: + ann = maybe_arg.annotation + arg_line_text = lines[maybe_arg.lineno - 1] + name_end_col = _byte_col_to_char_col(arg_line_text, maybe_arg.col_offset) + len(maybe_arg.arg) + ann_end_line = ann.end_lineno or ann.lineno + ann_end_line_text = lines[ann_end_line - 1] + ann_end_col = _byte_col_to_char_col(ann_end_line_text, ann.end_col_offset or (ann.col_offset + 1)) + removals.append((maybe_arg.lineno, name_end_col, ann_end_line, ann_end_col)) + + if not removals: + return source_code + + # Sort removals in reverse order so later removals don't affect earlier offsets + removals.sort(key=lambda r: (r[0], r[1]), reverse=True) + + for start_line, start_col, end_line, end_col in removals: + if start_line == end_line: + # Single-line removal + line_idx = start_line - 1 + line = lines[line_idx] + lines[line_idx] = line[:start_col] + line[end_col:] + else: + # Multi-line removal (rare but possible for complex annotations) + first_idx = start_line - 1 + last_idx = end_line - 1 + lines[first_idx] = lines[first_idx][:start_col] + lines[last_idx][end_col:] + del lines[first_idx + 1 : last_idx + 1] + + return "".join(lines) + + +# ============================================================================ +# Dependencies reading +# ============================================================================ + + +def read_dependencies(toml_path: Path) -> list[str]: + """Read pip dependencies from a pyproject.toml or component TOML file.""" + with open(toml_path, "rb") as f: + data = tomllib.load(f) + # Standard pyproject.toml format + deps = data.get("project", {}).get("dependencies", []) + if deps: + return list(deps) + return [] + + +# ============================================================================ +# Code generation +# ============================================================================ + + +def _build_argparse_code(spec: FunctionSpec) -> str: + """Generate argparse wrapper code for the component function. + + Type-specific definitions (e.g. _deserialize_bool, import json) are placed + right before 'import argparse', matching the Cloud-Pipelines SDK layout. + """ + # Collect definitions needed by parameter types (deduplicated by content) + definitions: dict[str, str] = {} + for param in spec.inputs + spec.outputs: + if param.tangle_type and param.tangle_type in _TYPE_DEFINITIONS: + defn = _TYPE_DEFINITIONS[param.tangle_type] + definitions[defn] = defn # dedup by content + + # If there are return outputs, we need serializer helpers and json import + has_return_outputs = len(spec.return_params) > 0 + if has_return_outputs: + # Check if any return output needs json.dumps + needs_json = any( + _TYPE_TO_SERIALIZER.get(p.tangle_type or "String", "") == "json.dumps" for p in spec.return_params + ) + if needs_json: + definitions["import json"] = "import json" + + lines = sorted(definitions.values()) + [ + "import argparse", + f"_parser = argparse.ArgumentParser(prog={repr(spec.component_name)}, " + f"description={repr(spec.description or '')})", + ] + + # Add arguments for all inputs and file-based outputs (OutputPath params) + all_params = spec.inputs + spec.outputs + for param in all_params: + flag = "--" + param.yaml_name.replace("_", "-") + is_required = param.kind == "output" or not param.optional + line = ( + f'_parser.add_argument("{flag}", dest="{param.name}", ' + f"type={param.deserializer}, required={is_required}, " + f"default=argparse.SUPPRESS)" + ) + lines.append(line) + + # Add ----output-paths argument for NamedTuple return outputs + if has_return_outputs: + n = len(spec.return_params) + lines.append(f'_parser.add_argument("----output-paths", dest="_output_paths", ' f"type=str, nargs={n})") + + lines.append("_parsed_args = vars(_parser.parse_args())") + + if has_return_outputs: + lines.append('_output_files = _parsed_args.pop("_output_paths", [])') + + lines.append("") + lines.append(f"_outputs = {spec.name}(**_parsed_args)") + + # Single return value (not NamedTuple) must be wrapped in a list + # to be zipped with the serializers and output paths + if has_return_outputs and spec.single_return_output: + lines.append("_outputs = [_outputs]") + + # Add output serialization for return outputs + if has_return_outputs: + lines.append("") + serializers = [] + for rp in spec.return_params: + serializer = _TYPE_TO_SERIALIZER.get(rp.tangle_type or "String", "_serialize_str") + serializers.append(f" {serializer},") + lines.append("_output_serializers = [") + lines.extend(serializers) + lines.append("]") + lines.append("") + lines.append("import os") + lines.append("for idx, output_file in enumerate(_output_files):") + lines.append(" try:") + lines.append(" os.makedirs(os.path.dirname(output_file))") + lines.append(" except OSError:") + lines.append(" pass") + lines.append(" with open(output_file, 'w') as f:") + lines.append(" f.write(_output_serializers[idx](_outputs[idx]))") + + return "\n".join(lines) + + +def _build_args_section(spec: FunctionSpec) -> list[Any]: + """Build the YAML args section with input/output placeholders.""" + args: list[Any] = [] + + all_params = spec.inputs + spec.outputs + for param in all_params: + flag = "--" + param.yaml_name.replace("_", "-") + + # Determine the placeholder type + if param.kind == "output": + placeholder = {"outputPath": param.yaml_name} + elif param.kind == "input_path": + placeholder = {"inputPath": param.yaml_name} + else: + placeholder = {"inputValue": param.yaml_name} + + if param.optional: + # Wrap in if/cond/isPresent/then for optional params + args.append( + { + "if": { + "cond": {"isPresent": param.yaml_name}, + "then": [flag, placeholder], + } + } + ) + else: + args.append(flag) + args.append(placeholder) + + # Add ----output-paths entries for NamedTuple return outputs + if spec.return_params: + args.append("----output-paths") + for rp in spec.return_params: + args.append({"outputPath": rp.yaml_name}) + + return args + + +def _build_pip_install_command(deps: list[str]) -> list[str]: + """Build the pip install command prefix for the container.""" + if not deps: + return [] + quoted = " ".join(repr(str(d)) for d in deps) + install_cmd = ( + f"PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install " f"--quiet --no-warn-script-location {quoted}" + ) + return [ + "sh", + "-c", + f'({install_cmd} || {install_cmd} --user) && "$0" "$@"', + ] + + +def _build_python_source( + spec: FunctionSpec, + mode: Literal["inline", "bundle"], + bundled_modules_b64: str | None = None, +) -> str: + """Build the full Python source code to embed in the YAML. + + For inline mode: helper functions + stripped source + argparse wrapper. + For bundle mode: helper functions + sys.modules injection + stripped source + argparse wrapper. + """ + parts: list[str] = [] + + # Add _make_parent_dirs_and_return_path helper if needed + has_output_path = any(p.kind == "output" for p in spec.params) + if has_output_path: + parts.append(_MAKE_PARENT_DIRS_HELPER) + + # Add _serialize_str helper if needed for NamedTuple return outputs + if spec.return_params: + needs_serialize_str = any( + _TYPE_TO_SERIALIZER.get(p.tangle_type or "String", "_serialize_str") == "_serialize_str" + for p in spec.return_params + ) + if needs_serialize_str: + parts.append(_SERIALIZE_STR_HELPER) + + # For bundle mode: add sys.modules injection from compressed embedded source text + if mode == "bundle" and bundled_modules_b64: + parts.append(ModuleBundler.build_injection(bundled_modules_b64)) + + # Add the source code (type-hint-stripped) + # Use full module source when available — this preserves helper functions defined + # outside the target function, module-level imports, and constants. + if spec.module_source_stripped: + parts.append(spec.module_source_stripped) + else: + parts.append(spec.source_code_stripped) + + # Add argparse wrapper + parts.append(_build_argparse_code(spec)) + + full_source = "\n\n".join(parts) + # Clean up consecutive blank lines + full_source = re.sub(r"\n\n\n+", "\n\n", full_source).strip("\n") + "\n" + return full_source + + +def _serialize_default(value: Any, tangle_type: str | None) -> str | None: + """Serialize a default value to a string for YAML.""" + if value is inspect.Parameter.empty or value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, bool): + return str(value) + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, (list, dict)): + return json.dumps(value, sort_keys=True) + return str(value) + + +# ============================================================================ +# Component YAML building +# ============================================================================ + + +def build_component_dict( + spec: FunctionSpec, + container_image: str, + dependencies: list[str], + annotations: dict[str, str], + mode: Literal["inline", "bundle"] = "inline", + bundled_modules_b64: str | None = None, +) -> dict[str, Any]: + """Build the complete component YAML dict. + + Args: + spec: Extracted function specification + container_image: Docker image for the container + dependencies: List of pip dependencies + annotations: Metadata annotations dict + mode: Generation mode + bundled_modules_b64: Base64-encoded pickled modules (bundle mode only) + + Returns: + Dict representing the full component YAML structure. + """ + # Build inputs + inputs = [] + for param in spec.inputs: + input_spec: dict[str, Any] = { + "name": param.yaml_name, + "type": param.tangle_type, + } + if param.description: + input_spec["description"] = param.description + if param.default is not inspect.Parameter.empty and param.default is not None: + serialized = _serialize_default(param.default, param.tangle_type) + if serialized is not None: + input_spec["default"] = serialized + if param.optional: + input_spec["optional"] = True + inputs.append(input_spec) + + # Build outputs (OutputPath params + NamedTuple return fields) + outputs = [] + for param in spec.all_outputs: + output_spec: dict[str, Any] = { + "name": param.yaml_name, + "type": param.tangle_type, + } + if param.description: + output_spec["description"] = param.description + outputs.append(output_spec) + + # Build implementation + all_deps = list(dependencies) + + pip_install = _build_pip_install_command(all_deps) + python_source = _build_python_source(spec, mode, bundled_modules_b64) + args = _build_args_section(spec) + + shell_bootstrap = textwrap.dedent("""\ + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + """) + + command = pip_install + ["sh", "-ec", shell_bootstrap, python_source] + + # Tangle's schema rejects ``description: null``, so fall back to a generic + # placeholder when the function has no docstring. Users can override by + # adding a docstring to the function (its first paragraph becomes the + # description — see ``extract_function_spec``). + description = spec.description or f"{spec.component_name} component" + + component: dict[str, Any] = { + "name": spec.component_name, + "description": description, + } + + if annotations: + component["metadata"] = {"annotations": annotations} + + if inputs: + component["inputs"] = inputs + if outputs: + component["outputs"] = outputs + + component["implementation"] = { + "container": { + "image": container_image, + "command": command, + "args": args, + } + } + + return component + + +# ============================================================================ +# Top-level generation function +# ============================================================================ + + +def generate_component_yaml( + file_path: Path, + output_path: Path, + container_image: str, + function_name: str | None = None, + dependencies_from: Path | None = None, + mode: Literal["inline", "bundle"] = "inline", + custom_name: str | None = None, + custom_annotations: dict[str, str] | None = None, + strip_code: bool = False, + strip_source_path: bool = False, + resolve_root: Path | None = None, + emit_generation_annotations: bool = True, + path_annotation_mode: Literal["oss", "td_legacy"] = "oss", +) -> bool: + """Generate a component YAML file from a Python function. + + Args: + file_path: Path to the Python source file + output_path: Where to write the generated YAML + container_image: Docker image reference + function_name: Function to extract (auto-detected if None) + dependencies_from: Path to pyproject.toml with pip dependencies + mode: "inline" for single-file, "bundle" for multi-file + custom_name: Override the component name + custom_annotations: Additional annotations to merge + strip_code: Omit python_original_code annotation + strip_source_path: Omit python_original_code_path annotation + resolve_root: Root directory for resolving local module imports in bundle + mode. Defaults to ``file_path.parent``. Set this when local modules + live in sibling directories (e.g. ``src/utils`` alongside ``src/components``). + emit_generation_annotations: Persist tangle-cli regeneration context + annotations. Disable for downstream legacy snapshot compatibility. + path_annotation_mode: ``"oss"`` always records source/YAML paths relative + to their common ancestor. ``"td_legacy"`` only uses that relative + common-root behavior inside a git checkout; outside git it records + ``file_path.name`` / ``output_path.name`` like legacy tangle-deploy. + + Returns: + True on success, False on failure. + """ + try: + if path_annotation_mode not in {"oss", "td_legacy"}: + raise ValueError("path_annotation_mode must be 'oss' or 'td_legacy'") + + # 1. Extract metadata from source (AST-based, before module loading) + file_metadata, resolved_func_name = extract_file_metadata(file_path, function_name) + if not resolved_func_name: + raise ValueError(f"No public functions found in {file_path}") + + # 2. Load module and get function + # Only add resolve_root to sys.path in bundle mode — in inline mode the + # sibling modules won't be embedded, so letting the import succeed would + # produce YAML that fails at runtime in the container. + extra_paths = [resolve_root] if resolve_root and mode == "bundle" else None + module = load_python_module(file_path, extra_sys_path=extra_paths) + func = get_function_from_module(module, resolved_func_name) + + # 3. Extract interface, passing pre-computed metadata + spec = extract_interface(func, docstring_metadata=file_metadata) + if custom_name: + spec.component_name = custom_name + + # Populate full module source (preserves helper functions, imports, constants) + # Remove cloud_pipelines import since it's only used for type annotations + module_source = file_path.read_text() + lines = module_source.split("\n") + lines = [ + line for line in lines if not (line.strip().startswith(("from cloud_pipelines", "import cloud_pipelines"))) + ] + filtered_source = "\n".join(lines) + filtered_source = _strip_main_guard(filtered_source) + # Strip python-pipeline authoring imports + @task/@pipeline/@subpipeline + # decorators so the baked runtime program does not re-run the authoring + # decorator (which would turn the function into a CallableRef and crash). + # Operates on module_source_stripped only; python_original_code stays + # byte-verbatim (it is read separately from module_code below). + filtered_source = _strip_authoring_constructs(filtered_source) + spec.module_source_stripped = _strip_type_hints(filtered_source) + + # 3. Read dependencies + deps: list[str] = [] + if dependencies_from: + deps = read_dependencies(dependencies_from) + + # 4. Build annotations + directory = file_path.parent.resolve() + module_code = file_path.read_text() + + annotations: dict[str, str] = { + "cloud_pipelines.net": "true", + "components new regenerate python-function-component": "true", + } + if not strip_source_path: + annotations["python_original_code_path"] = file_path.name + if not strip_code: + annotations["python_original_code"] = module_code + + # Add all docstring metadata to annotations (version, updated_at, custom keys) + # Skip "name" and "description" since they're used for top-level fields, not annotations + for key, value in spec.docstring_metadata.items(): + if key not in ("name", "description"): + annotations[key] = value + + if deps: + annotations["python_dependencies"] = json.dumps(deps) + + if emit_generation_annotations: + annotations["tangle_cli_generation_function_name"] = resolved_func_name + annotations["tangle_cli_generation_mode"] = mode + + # Use the common ancestor of source and output so both paths are clean + # forward references (no ".."). This lets later local maintenance + # commands find the source even when YAML is generated into a separate + # output directory. TD legacy compatibility keeps basename-only paths + # outside a git checkout to preserve historical snapshots. + resolved_source = file_path.resolve() + resolved_output = output_path.resolve() + common_dir = Path(os.path.commonpath([resolved_source, resolved_output])) + git_root = get_git_root(directory) + use_common_paths = path_annotation_mode == "oss" or git_root is not None + + def _path_annotation(path: Path) -> str: + if use_common_paths: + try: + return str(path.resolve().relative_to(common_dir)) + except ValueError: + return str(path) + return path.name + + if not strip_source_path: + annotations["python_original_code_path"] = _path_annotation(file_path) + annotations["component_yaml_path"] = _path_annotation(output_path) + if emit_generation_annotations: + if dependencies_from: + annotations["tangle_cli_generation_dependencies_from"] = _path_annotation(dependencies_from) + if resolve_root: + annotations["tangle_cli_generation_resolve_root"] = _path_annotation(resolve_root) + + # Git info — use the same common ancestor as git_relative_dir when common paths are active. + if git_root: + git_info = get_git_info(common_dir) + git_info.pop("_git_root", None) + # Override git_relative_dir to be the common ancestor + try: + git_info["git_relative_dir"] = str(common_dir.relative_to(git_root)) + except ValueError: + pass + annotations.update(git_info) + else: + git_info = get_git_info(directory) + git_info.pop("_git_root", None) + annotations.update(git_info) + + # Custom annotations + if custom_annotations: + annotations.update(custom_annotations) + + # Filter None values (annotation values must be strings) + annotations = {k: v for k, v in annotations.items() if isinstance(v, str)} + + # 5. Handle bundle mode — embed source text of local modules + # (not bytecode, which is Python-version-specific) + bundled_modules_b64: str | None = None + if mode == "bundle": + module_sources = ModuleBundler.collect_sources( + file_path, + resolve_root=resolve_root, + pip_deps=deps, + source=spec.module_source_stripped, + ) + if module_sources: + bundled_modules_b64 = ModuleBundler.encode(module_sources) + if bundled_modules_b64: + sorted_names = sorted(module_sources.keys(), key=lambda k: (k.count("."), k)) + annotations["bundled_modules"] = json.dumps(sorted_names) + + # 6. Build and write YAML + component = build_component_dict( + spec=spec, + container_image=container_image, + dependencies=deps, + annotations=annotations, + mode=mode, + bundled_modules_b64=bundled_modules_b64, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + f.write(dump_yaml(component, width=120)) + + return True + + except AuthoringStripError: + # TaskEnv authoring-violation (§3.5): fail LOUD with the actionable + # guidance instead of swallowing it into a warning + False. A silent + # False would only resurface later as a confusing missing/broken + # component at hydrate or backend run time, defeating the + # "fail fast with a clear generator error" intent. Every OTHER failure + # keeps the conservative warn + return False behaviour below. + raise + except Exception as e: + warnings.warn(f"Error generating component YAML: {e}") + return False diff --git a/packages/tangle-cli/src/tangle_cli/component_generator.py b/packages/tangle-cli/src/tangle_cli/component_generator.py new file mode 100644 index 0000000..4a4fafc --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_generator.py @@ -0,0 +1,298 @@ +"""Generate Tangle component YAML files from local Python functions.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +import yaml + +# Pin the default runtime image by digest so generated component YAML is reproducible. +# The tag documents the Python line; the digest pins the linux/amd64 image +# used by Tangle execution. Authors can still pass --image to choose a +# different runtime explicitly. +DEFAULT_CONTAINER_IMAGE = "python:3.12@sha256:b8163b64b37051de76577219aa4d5e9b95dc12a2e6c8cb438793c7adb3026016" + + +class ComponentGenerator: + """Generic Python-function component generation orchestration. + + The heavy Python-function introspection and YAML construction lives in + :mod:`tangle_cli.component_from_func`. This class owns the surrounding + authoring workflow: dependency discovery, output-path derivation, existing + image reuse, partial-output cleanup, and logging. Downstreams should + subclass or compose this class rather than wrapping module globals. + """ + + default_container_image = DEFAULT_CONTAINER_IMAGE + + def __init__( + self, + *, + logger: Any | None = None, + verbose: bool = False, + default_container_image: str | None = None, + ) -> None: + self.logger = logger + self.verbose = verbose + if default_container_image is not None: + self.default_container_image = default_container_image + + def _log(self, message: str, *, err: bool = False) -> None: + if self.logger is not None: + log_method = getattr(self.logger, "error", None) if err else getattr(self.logger, "info", None) + if log_method is not None: + log_method(message) + return + if self.verbose: + print(message) + + def find_dependencies_file(self, python_file: Path) -> Path | None: + """Find a dependency file for a Python component source file. + + Looks for a component-specific TOML file next to the Python file, then a + ``pyproject.toml`` in the file's directory or up to three parent directories. + """ + + file_dir = python_file.parent + file_base = python_file.stem + toml_variations = [ + file_dir / f"{file_base.replace('_', '-')}.toml", + file_dir / f"{file_base}.toml", + ] + for toml_file in toml_variations: + if toml_file.exists(): + return toml_file + + search_dirs = [ + file_dir, + file_dir.parent, + file_dir.parent.parent, + file_dir.parent.parent.parent, + ] + for search_dir in search_dirs: + pyproject = search_dir / "pyproject.toml" + if pyproject.exists(): + return pyproject + return None + + def determine_output_path( + self, + input_file: Path, + output: Path | None = None, + output_is_dir: bool = False, + use_legacy_naming: bool = False, + ) -> Path: + """Determine the YAML output path for a generated component.""" + + base_name = input_file.stem.replace("_", "-") + if output: + output_name = base_name + ".yaml" + if output.is_dir() or output_is_dir or (not output.suffix and not output.exists()): + return output / output_name + return output + + if use_legacy_naming: + legacy_name = input_file.stem + ".component.yaml" + output_dir = input_file.parent / "generated" + return output_dir / legacy_name + + return input_file.parent / (base_name + ".yaml") + + def extract_image_from_yaml(self, yaml_path: Path) -> str | None: + """Extract an existing component container image, if any.""" + + if not yaml_path.exists(): + return None + try: + with yaml_path.open(encoding="utf-8") as f: + existing_yaml = yaml.safe_load(f) + impl = existing_yaml.get("implementation", {}) if isinstance(existing_yaml, dict) else {} + return impl.get("container", {}).get("image") + except Exception: + return None + + def generate_component_yaml( + self, + *, + file_path: Path, + output_path: Path, + container_image: str, + function_name: str | None = None, + dependencies_from: Path | None = None, + mode: Literal["inline", "bundle"] = "inline", + custom_name: str | None = None, + custom_annotations: dict[str, str] | None = None, + strip_code: bool = False, + strip_source_path: bool = False, + resolve_root: Path | None = None, + emit_generation_annotations: bool = True, + ) -> bool: + """Generate component YAML from a Python function source file.""" + + from tangle_cli.component_from_func import generate_component_yaml + + return generate_component_yaml( + file_path=file_path, + output_path=output_path, + container_image=container_image, + function_name=function_name, + dependencies_from=dependencies_from, + mode=mode, + custom_name=custom_name, + custom_annotations=custom_annotations, + strip_code=strip_code, + strip_source_path=strip_source_path, + resolve_root=resolve_root, + emit_generation_annotations=emit_generation_annotations, + ) + + def regenerate_yaml( + self, + python_file: Path, + output_path: Path | None = None, + function_name: str | None = None, + custom_name: str | None = None, + image: str | None = None, + dependencies_from: Path | None = None, + strip_code: bool = False, + strip_source_path: bool = False, + mode: str = "inline", + resolve_root: Path | None = None, + emit_generation_annotations: bool = True, + ) -> bool: + """Regenerate a YAML component from a Python function source file.""" + + if not python_file.exists(): + self._log(f" ❌ File not found: {python_file}", err=True) + return False + + final_output = output_path or self.determine_output_path(python_file) + resolved_image = image or self.extract_image_from_yaml(final_output) or self.default_container_image + deps_file = dependencies_from or self.find_dependencies_file(python_file) + if deps_file: + self._log(f" Found dependencies: {deps_file}") + + final_output.parent.mkdir(parents=True, exist_ok=True) + return self.run_generation( + python_file=python_file, + final_output=final_output, + image=resolved_image, + func_name=function_name, + deps_file=deps_file, + custom_name=custom_name, + strip_code=strip_code, + strip_source_path=strip_source_path, + mode=mode, + resolve_root=resolve_root, + emit_generation_annotations=emit_generation_annotations, + ) + + def run_generation( + self, + *, + python_file: Path, + final_output: Path, + image: str, + func_name: str | None, + deps_file: Path | None, + custom_name: str | None, + strip_code: bool, + strip_source_path: bool, + mode: str = "inline", + resolve_root: Path | None = None, + emit_generation_annotations: bool = True, + ) -> bool: + """Execute component generation and clean up partial output on failure.""" + + try: + function_detail = f" function {func_name!r}" if func_name else "" + self._log(f" Generating component from {python_file.name}{function_detail}...") + success = self.generate_component_yaml( + file_path=python_file, + output_path=final_output, + container_image=image, + function_name=func_name, + dependencies_from=deps_file, + mode=mode, # type: ignore[arg-type] + custom_name=custom_name, + strip_code=strip_code, + strip_source_path=strip_source_path, + resolve_root=resolve_root, + emit_generation_annotations=emit_generation_annotations, + ) + if not success: + self._log(" ❌ Failed to generate component", err=True) + return False + self._log(f" ✅ Generated: {final_output}") + return True + except Exception as exc: + if exc.__class__.__name__ == "AuthoringStripError": + if final_output.exists(): + final_output.unlink() + raise + self._log(f" ❌ Error: {exc}", err=True) + if final_output.exists(): + final_output.unlink() + return False + + +def find_dependencies_file(python_file: Path) -> Path | None: + """Find a dependency file for a Python component source file.""" + + return ComponentGenerator().find_dependencies_file(python_file) + + +def determine_output_path( + input_file: Path, + output: Path | None = None, + output_is_dir: bool = False, + use_legacy_naming: bool = False, +) -> Path: + """Determine the YAML output path for a generated component.""" + + return ComponentGenerator().determine_output_path( + input_file, + output, + output_is_dir, + use_legacy_naming, + ) + + +def regenerate_yaml( + python_file: Path, + output_path: Path | None = None, + function_name: str | None = None, + custom_name: str | None = None, + image: str | None = None, + dependencies_from: Path | None = None, + strip_code: bool = False, + strip_source_path: bool = False, + verbose: bool = False, + mode: str = "inline", + resolve_root: Path | None = None, + logger: Any | None = None, +) -> bool: + """Regenerate a YAML component from a Python function source file.""" + + return ComponentGenerator(logger=logger, verbose=verbose).regenerate_yaml( + python_file=python_file, + output_path=output_path, + function_name=function_name, + custom_name=custom_name, + image=image, + dependencies_from=dependencies_from, + strip_code=strip_code, + strip_source_path=strip_source_path, + mode=mode, + resolve_root=resolve_root, + ) + + +__all__ = [ + "ComponentGenerator", + "DEFAULT_CONTAINER_IMAGE", + "determine_output_path", + "find_dependencies_file", + "regenerate_yaml", +] diff --git a/packages/tangle-cli/src/tangle_cli/component_inspector.py b/packages/tangle-cli/src/tangle_cli/component_inspector.py new file mode 100644 index 0000000..92f16c5 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_inspector.py @@ -0,0 +1,494 @@ +"""Component inspection helpers for OpenAPI-backed Tangle clients.""" + +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin, urlparse +from weakref import WeakKeyDictionary + +import yaml + +from tangle_cli.handler import TangleCliHandler +from tangle_cli.models import ComponentInfo, ComponentSpec + +if TYPE_CHECKING: + from tangle_cli.client import TangleApiClient + +# ============================================================================ +# Client helpers +# ============================================================================ + + +def _request_path(client: TangleApiClient, path: str) -> Any: + """Fetch an API-origin path using the client's auth settings. + + ``component_library.yaml`` is not guaranteed to be represented as an + OpenAPI operation, but it is served from the same origin as the API. This + helper preserves the static client's base URL, session, and auth/header + precedence even though the library YAML is outside the OpenAPI schema. + """ + + custom_request_path = getattr(client, "request_path", None) + if callable(custom_request_path): + response = custom_request_path(path) + else: + response = client._make_request("GET", path) + response.raise_for_status() + return response + + +def _published_components( + client: TangleApiClient, + *, + include_deprecated: bool = False, + name_substring: str | None = None, + published_by_substring: str | None = None, + digest: str | None = None, +) -> list[ComponentInfo]: + return client.list_published_component_infos( + include_deprecated=include_deprecated, + name_substring=name_substring, + published_by_substring=published_by_substring, + digest=digest, + ) + + +def _resolve_digest(client: TangleApiClient, digest: str) -> str: + current = digest + seen: set[str] = set() + + while True: + if current in seen: + break + seen.add(current) + + published = _published_components( + client, digest=current, include_deprecated=True + ) + if not published: + break + + meta = published[0] + if not meta.deprecated: + break + + superseded_by = meta.superseded_by + if not superseded_by: + break + + current = superseded_by + + return current + + +# ============================================================================ +# Standard component library cache +# ============================================================================ + +_COMPONENT_LIBRARY_PATH = "/component_library.yaml" + +# Component libraries are fetched through authenticated clients and may differ by +# base URL or caller. Cache by client identity so long-lived processes can safely +# use multiple Tangle sessions without leaking library entries across them. +_LibraryState = tuple[dict[str, Any], dict[str, dict[str, Any]]] +_component_libraries_by_client: WeakKeyDictionary[Any, _LibraryState] = WeakKeyDictionary() + + +def _library_fetch_path(client: TangleApiClient, url: str) -> str | None: + """Return a same-origin path for a component-library URL, or None. + + The component library is supplied by the Tangle API origin. Treat URLs + inside it as untrusted input: relative and same-origin URLs are okay, but + cross-origin URLs would make the CLI issue arbitrary outbound requests from + the operator's workstation/CI runner. + """ + + base = client.base_url.rstrip("/") + "/" + base_parts = urlparse(base) + target_parts = urlparse(urljoin(base, url)) + if target_parts.scheme != base_parts.scheme or target_parts.netloc != base_parts.netloc: + return None + + path = target_parts.path or "/" + if target_parts.query: + path = f"{path}?{target_parts.query}" + return path + + +def _fetch_library_component(client: TangleApiClient, url: str) -> ComponentSpec: + path = _library_fetch_path(client, url) + if path is None: + return ComponentSpec() + + try: + response = _request_path(client, path) + return ComponentSpec.from_yaml(response.text) + except Exception: + return ComponentSpec() + + +def _parse_component_library(raw: dict[str, Any], client: TangleApiClient) -> dict[str, Any]: + """Parse the component library YAML into entries with full specs. + + Each entry has ``url``, ``digest``, and ``spec`` (full, unstripped). Use + :func:`_strip_entry` to produce a lightweight view on demand. + """ + + result: dict[str, Any] = {"folders": []} + for folder in raw.get("folders", []): + parsed_components = [] + for comp in folder.get("components", []): + entry: dict[str, Any] = {} + url = comp.get("url") + if url: + entry["url"] = url + + component = ComponentSpec.from_dict(comp) + + # Fall back to URL fetch if no spec resolved. URLs come from the + # server-supplied component_library.yaml, so only fetch relative or + # same-origin URLs through the configured API client. + if not component.data and url: + component = _fetch_library_component(client, url) + + if component.data: + component.ensure_digest() + entry["spec"] = component.data + if component.digest: + entry["digest"] = component.digest + parsed_components.append(entry) + result["folders"].append({"name": folder.get("name"), "components": parsed_components}) + return result + + +def _strip_entry(entry: dict[str, Any]) -> dict[str, Any]: + """Return a copy of a library entry with the spec stripped of bulky fields.""" + + spec = ComponentSpec(data=entry.get("spec") or {}) + result = {k: v for k, v in entry.items() if k != "spec"} + result["spec"] = spec.stripped_spec + return result + + +def _ensure_library_loaded(client: TangleApiClient) -> _LibraryState: + """Fetch and cache the component library for this client if needed.""" + + cached = _component_libraries_by_client.get(client) + if cached is not None: + return cached + + try: + response = _request_path(client, _COMPONENT_LIBRARY_PATH) + raw = yaml.safe_load(response.text) + parsed = _parse_component_library(raw, client) + cache: dict[str, dict[str, Any]] = {} + for folder in parsed.get("folders", []): + for comp in folder.get("components", []): + name = (comp.get("spec") or {}).get("name", "") + if name: + cache[name.lower()] = comp + state = (parsed, cache) + except Exception: + state = ({"folders": []}, {}) + + _component_libraries_by_client[client] = state + return state + + +class ComponentInspector(TangleCliHandler): + """Inspector for published component metadata and specs. + + Generic inspection and search behavior lives here. Downstreams provide an + authenticated client by subclassing the shared handler or by passing + ``client=`` explicitly in tests/callers. + """ + + def get_standard_library(self) -> dict[str, Any]: + """Return the standard component library organised by folders. + + Each component entry has a stripped spec (no implementation blocks), an + optional ``url``, and a ``digest``. + """ + + library_full, _ = _ensure_library_loaded(self._require_client()) + return { + "folders": [ + { + "name": folder.get("name"), + "components": [_strip_entry(comp) for comp in folder.get("components", [])], + } + for folder in library_full.get("folders", []) + ], + } + + def inspect_by_digest( + self, + digest: str, + full_spec: bool = False, + follow_deprecated: bool = False, + ) -> dict[str, Any]: + """Inspect a single component by digest. + + Fetches the full spec and publication metadata via static client helpers. + """ + + client = self._require_client() + if follow_deprecated: + resolved = _resolve_digest(client, digest) + if resolved != digest: + digest = resolved + + comp = _get_component_spec(client, digest) + if comp is None: + _, library_cache = _ensure_library_loaded(client) + for entry in library_cache.values(): + if entry.get("digest") == digest: + out = _strip_entry(entry) if not full_spec else dict(entry) + return { + "status": "success", + "source": "component_library", + "transparent": True, + "transparency_reason": "curated standard component from the component library", + "name": (entry.get("spec") or {}).get("name", ""), + **out, + } + return {"status": "not_found", "digest": digest, "error": f"Component not found: {digest}"} + + published = _published_components(client, digest=digest, include_deprecated=True) + pub_info = published[0] if published else None + + if pub_info: + info = pub_info + else: + info = ComponentInfo(digest=digest) + info.version = comp.version if comp else None + + info.component_spec = comp + _backfill_version_from_spec(info) + + result: dict[str, Any] = {"status": "success"} + if not pub_info: + result["published"] = False + if comp: + result["name"] = comp.name + transparent, transparency_reason = self.transparency_check(comp) + result["transparent"] = transparent + result["transparency_reason"] = transparency_reason + result.update(info.to_dict(strip_spec=not full_spec)) + if comp: + git_source = _resolve_git_source(comp) + if git_source: + result["source"] = git_source + return result + + def inspect_by_name( + self, + name: str, + include_all_versions: bool = False, + include_deprecated: bool = False, + full_spec: bool = False, + published_by: str | None = None, + ) -> dict[str, Any]: + """Inspect component(s) by name.""" + + client = self._require_client() + published = _published_components( + client, + name_substring=name, + include_deprecated=include_deprecated, + published_by_substring=published_by, + ) + published = [c for c in published if str(c.name or "").lower() == name.lower()] + + if not published: + _, library_cache = _ensure_library_loaded(client) + entry = library_cache.get(name.lower()) + if entry: + out = _strip_entry(entry) if not full_spec else dict(entry) + return { + "status": "success", + "source": "component_library", + "transparent": True, + "transparency_reason": "curated standard component from the component library", + "name": name, + **out, + } + return { + "status": "not_found", + "query": name, + "message": f"No published component found with name: {name}", + } + + def _version_key(component: ComponentInfo) -> tuple[int, ...]: + """Parse version string into numeric tuple for proper sorting.""" + + v = str(component.version or "0.0.1") + try: + return tuple(int(p) for p in v.split(".")) + except ValueError: + return (0, 0, 1) + + published.sort(key=_version_key, reverse=True) + + if not include_all_versions: + published = published[:1] + + versions: list[dict[str, Any]] = [] + for info in published: + if info.digest: + try: + info.component_spec = _get_component_spec(client, info.digest) + _backfill_version_from_spec(info) + except Exception as e: + info.spec_error = str(e) + entry = info.to_dict(strip_spec=not full_spec) + if info.component_spec: + transparent, transparency_reason = self.transparency_check(info.component_spec) + entry["transparent"] = transparent + entry["transparency_reason"] = transparency_reason + git_source = _resolve_git_source(info.component_spec) + if git_source: + entry["source"] = git_source + versions.append(entry) + + return { + "status": "success", + "name": name, + "version_count": len(versions), + "versions": versions, + } + + def search_components( + self, + name: str | None = None, + include_deprecated: bool = False, + published_by: str | None = None, + digest: str | None = None, + ) -> dict[str, Any]: + """Search for published components.""" + + components = _published_components( + self._require_client(), + name_substring=name, + include_deprecated=include_deprecated, + published_by_substring=published_by, + digest=digest, + ) + + results = [ + { + "name": comp.name, + "digest": comp.digest, + "version": comp.version, + "deprecated": comp.deprecated, + "description": (comp.description or "")[:200], + } + for comp in components + ] + + return { + "status": "success", + "query": name, + "count": len(results), + "components": results, + } + + @staticmethod + def transparency_check(spec: ComponentSpec) -> tuple[bool, str]: + """Check if a component's definition is transparent (source-inspectable). + + Returns a ``(transparent, reason)`` tuple. The *reason* is a short + human-readable explanation of **why** the component was classified as + transparent or opaque so that consuming agents can understand the decision + before applying their own judgment. + """ + + ann = spec.annotations + + if ann.get("python_original_code"): + return True, "inline Python source code embedded in annotations" + + canonical = ann.get("canonical_location") + if isinstance(canonical, str) and canonical.startswith(("https://", "http://")): + return True, f"canonical_location annotation points to {canonical}" + + if ann.get("git_remote_url") and ( + ann.get("component_yaml_path") or ann.get("git_relative_dir") + ): + return True, f"git source metadata links to {ann['git_remote_url']}" + + impl = spec.implementation or {} + container = impl.get("container", {}) + image = container.get("image", "") + if any(image.startswith(prefix) for prefix in _TRANSPARENT_IMAGE_PREFIXES): + return ( + True, + f"uses standard public base image ({image})" + " — code logic is in the component definition, not hidden in the container", + ) + + return False, "no inline source, canonical location, git metadata, or standard public image found" + + +# ============================================================================ +# Private function-level implementation helpers +# ============================================================================ + + +_TRANSPARENT_IMAGE_PREFIXES = ("python:", "ubuntu:", "debian:", "alpine:") + + +def _resolve_git_source(spec: ComponentSpec) -> dict[str, Any] | None: + """Extract git annotations and resolve to GitHub URLs and local paths.""" + + annotations = spec.annotations + git_url = annotations.get("git_remote_url") + if not git_url: + return None + + sha = annotations.get("git_remote_sha", "") + branch = annotations.get("git_remote_branch", "main") + component_yaml = annotations.get("component_yaml_path") + docs_path = annotations.get("documentation_path") + dockerfile = annotations.get("dockerfile_path") + relative_dir = annotations.get("git_relative_dir") + + repo_base = git_url.removesuffix(".git") + ref = sha or branch + + def _full_path(rel_path: str) -> str: + """Resolve a path relative to git_relative_dir into a git-root-relative path.""" + + if relative_dir: + return str(PurePosixPath(relative_dir, rel_path)) + return str(PurePosixPath(rel_path)) + + source: dict[str, Any] = {} + if component_yaml: + source["component_yaml"] = f"{repo_base}/blob/{ref}/{_full_path(component_yaml)}" + if docs_path: + source["docs"] = f"{repo_base}/blob/{ref}/{_full_path(docs_path)}" + if dockerfile: + source["dockerfile"] = f"{repo_base}/blob/{ref}/{_full_path(dockerfile)}" + if relative_dir: + source["source_dir"] = f"{repo_base}/tree/{ref}/{relative_dir}" + + return source if source else None + + +def _backfill_version_from_spec(info: ComponentInfo) -> None: + """Use attached component spec metadata when registry metadata omits version.""" + + if info.version is None and info.component_spec is not None: + info.version = info.component_spec.version + + +def _get_component_spec(client: TangleApiClient, digest: str) -> ComponentSpec | None: + try: + return client.get_component_spec(digest) + except Exception as exc: + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) == 404: + return None + raise diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py new file mode 100644 index 0000000..54966b4 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -0,0 +1,921 @@ +"""Publish components to the Tangle API. + +This module intentionally mirrors the generic publisher behavior from +``tangle-deploy`` while depending only on OSS ``tangle_cli`` primitives and the +checked-in/generated static API client. Provider-specific auth wrappers, +notification plumbing, and a separate ``publish-all`` CLI are kept downstream. +""" + +from __future__ import annotations + +import inspect +import os +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Any, Protocol + +import tangle_cli.utils as utils + +from .handler import TangleCliHandler +from .logger import Logger + +if TYPE_CHECKING: + from tangle_api.generated.models import ComponentSpec + + +class ProcessingOutcome(str, Enum): + """Outcome of processing one component publish operation.""" + + SKIP = "skip" + PROCEED = "proceed" + SUCCESS = "success" + ERROR = "error" + + +@dataclass +class ProcessingResult: + """Result for one component publish/deprecate processing step.""" + + outcome: ProcessingOutcome + local_version: str | None = None + latest_version: str | None = None + spec: Any = None + reason: str | None = None + digest: str | None = None + response: Any = None + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "status": self.outcome.value, + "outcome": self.outcome.value, + "local_version": self.local_version, + "latest_version": self.latest_version, + "reason": self.reason, + "digest": self.digest, + "response": _to_plain(self.response), + } + if self.spec is not None: + payload["name"] = getattr(self.spec, "name", None) + return {key: value for key, value in payload.items() if value is not None} + + +@dataclass(frozen=True) +class ComponentPublishContext: + """Structured context passed to component publish hooks. + + The context is additive: hooks may keep the original historical signatures, + or add a keyword-only ``context`` parameter to receive publisher metadata, + batch configuration, and accumulated per-component results. + """ + + publisher: "ComponentPublisher" + dry_run: bool + git_remote_sha: str | None = None + git_remote_branch: str | None = None + git_remote_url: str | None = None + git_repo: str | None = None + git_root: str | None = None + published_by: str | None = None + batch_config: Sequence[Mapping[str, Any]] | None = None + component_config: Mapping[str, Any] | None = None + component_path: str | None = None + result: ProcessingResult | None = None + results: Sequence[tuple[str, ProcessingResult]] = field(default_factory=tuple) + + +class ComponentPublishHook(Protocol): + """Extension hook for downstream publishers. + + Downstream packages can implement one or more methods to observe publish + batches (for example, to send notification summaries) without OSS importing + or knowing about those systems. Implementations that need richer metadata may + add ``context: ComponentPublishContext | None = None`` as a keyword + parameter; hooks without that parameter continue to work. + """ + + def before_batch( + self, + components_config: Sequence[Mapping[str, Any]], + context: ComponentPublishContext | None = None, + ) -> None: ... + + def after_component( + self, + component_path: str, + result: ProcessingResult, + context: ComponentPublishContext | None = None, + ) -> None: ... + + def after_batch( + self, + results: Sequence[tuple[str, ProcessingResult]], + context: ComponentPublishContext | None = None, + ) -> None: ... + + +# ============================================================================ +# Publisher +# ============================================================================ + + +class ComponentPublisher(TangleCliHandler): + """Publisher for Tangle components.""" + + component_spec_model: type[Any] | None = None + + def __init__( + self, + dry_run: bool = False, + git_remote_sha: str | None = None, + git_remote_branch: str | None = None, + git_remote_url: str | None = None, + git_repo: str | None = None, + git_root: str | Path | None = None, + published_by: str | None = None, + client: Any = None, + client_factory: Callable[[], Any] | None = None, + hooks: Sequence[ComponentPublishHook] | None = None, + logger: Logger | None = None, + base_url: str | None = None, + ) -> None: + """Initialize the ComponentPublisher. + + Args mirror the generic ``tangle-deploy`` publisher shape, with + provider-specific notification/auth fields intentionally omitted. + ``client_factory`` is a downstream seam for lazily constructing a custom + authenticated client; subclasses may also override :meth:`_get_client` + for more control. + """ + + super().__init__( + dry_run=dry_run, + client=client, + client_factory=client_factory, + logger=logger, + base_url=base_url, + ) + self.published_by = published_by + self.hooks = list(hooks or []) + self.results: list[tuple[str, ProcessingResult]] = [] + + git_info = utils.get_git_info(Path.cwd(), logger=self.log) + self._git_root = str(git_root or git_info.get("_git_root") or "") or None + self.git_remote_sha = git_remote_sha or git_info.get("git_remote_sha") + self.git_remote_branch = git_remote_branch or git_info.get("git_remote_branch") + self.git_remote_url = git_remote_url or git_info.get("git_remote_url") + self.git_repo = git_repo + + 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 + 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." + ) from exc + raise + return ComponentSpec + + def component_digest(self, component: Any) -> str | None: + """Return a published component digest from mapping or object shapes.""" + + if isinstance(component, Mapping): + digest = component.get("digest") + return str(digest) if digest else None + digest = getattr(component, "digest", None) + return str(digest) if digest else None + + def current_user_id(self, client: Any) -> str | None: + """Return the current Tangle user id for owner-scoped lookups.""" + + try: + user_info = client.users_me() + except Exception: + return None + if user_info is None: + return None + if isinstance(user_info, Mapping): + value = user_info.get("id") + else: + value = getattr(user_info, "id", None) + return str(value) if value else None + + def perform_version_check(self, spec: Any) -> ProcessingResult: + """Perform owner-scoped version checking for a component. + + If ``published_by`` is omitted, the current authenticated user is + resolved via ``client.users_me().id``. Failure to determine an owner is + an error so callers do not accidentally compare/deprecate components + owned by others. + """ + + local_version = spec.version + self.log.info(f" Local version: {local_version}") + + latest_version = None + + if self.dry_run: + test_version = os.environ.get("TEST_LATEST_VERSION") + if test_version: + latest_version = test_version + self.log.info(f" Remote version (test): {latest_version}") + else: + client = self._get_client() + if client is None: + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=str(local_version), + latest_version=None, + reason="Failed to create API client", + ) + + filter_by = self.published_by or self.current_user_id(client) + if not filter_by: + self.log.error( + "❌ Cannot determine current user — aborting to avoid deprecating components owned by others" + ) + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=str(local_version), + latest_version=None, + reason="Cannot determine current user for author filtering", + ) + + existing_components = client.find_existing_components( + spec.search_names, + verbose=False, + published_by=filter_by, + ) + + if existing_components: + for component in existing_components: + digest = self.component_digest(component) + if not digest: + continue + try: + full_spec = client.get_component_spec(digest) + remote_version = full_spec.version if full_spec else None + if remote_version and ( + not latest_version or utils.compare_versions(remote_version, latest_version) > 0 + ): + latest_version = remote_version + except Exception as exc: + self.log.warn(f" Warning: Failed to get version for component {digest[:16]}: {exc}") + continue + + if latest_version: + self.log.info(f" Remote version: {latest_version}") + else: + self.log.info( + f" ℹ️ Found {len(existing_components)} component(s) but couldn't extract version" + ) + + should_proceed = not latest_version or utils.compare_versions(local_version, latest_version) != 0 + + if should_proceed: + is_older = latest_version is not None and utils.compare_versions(latest_version, local_version) > 0 + version_suffix = " (older)" if is_older else "" + self.log.info( + " ➡️ Version " + + (f"{latest_version}{version_suffix}" if latest_version else "new") + + f" → {local_version}" + ) + return ProcessingResult( + outcome=ProcessingOutcome.PROCEED, + local_version=local_version, + latest_version=latest_version, + spec=spec, + ) + + self.log.info(f" ⏭️ Skipping: Version {local_version} unchanged") + + return ProcessingResult( + outcome=ProcessingOutcome.SKIP, + local_version=local_version, + latest_version=latest_version, + spec=spec, + reason=f"Version {local_version} unchanged (matches remote)", + ) + + def deprecate_old_components( + self, + existing_components: Sequence[Any], + new_digest: str, + ) -> int: + """Deprecate previous component versions after a successful publish. + + ``existing_components`` must already be owner-scoped by the caller. + This method refuses to operate without a client and skips the newly + published digest to avoid self-deprecation. + """ + + if not existing_components: + return 0 + + client = self._get_client() + if not client: + self.log.warn(" ⚠️ Cannot deprecate components without TangleApiClient") + return 0 + + self.log.info(f" Deprecating {len(existing_components)} previous version(s)...") + deprecation_count = 0 + + for old_component in existing_components: + old_digest = self.component_digest(old_component) + if old_digest and old_digest != new_digest: + try: + result = client.published_components_update( + digest=old_digest, + deprecated=True, + superseded_by=new_digest, + ) + if result: + deprecation_count += 1 + self.log.info(f" ✅ Successfully deprecated component {old_digest[:16]}...") + else: + self.log.warn( + f" ⚠️ No response from deprecation request for component {old_digest[:16]}..." + ) + except Exception as exc: + self.log.warn(f" ⚠️ Warning: Failed to deprecate component {old_digest[:16]}...: {exc}") + + if deprecation_count > 0: + self.log.info(f" ✅ Deprecated {deprecation_count} old version(s)") + + return deprecation_count + + def load_component_spec( + self, + component_path: str | Path, + *, + annotations: Mapping[str, str] | None = None, + ) -> "ComponentSpec": + """Load a component YAML file into the generated ``ComponentSpec`` model.""" + + text = read_component_yaml_text(component_path) + return self._component_spec_model().from_yaml(text, annotations=dict(annotations or {})) + + def prepare_component_for_publish( + self, + component_path: str | Path, + *, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: Mapping[str, str] | None = None, + ) -> "ComponentSpec": + """Load and apply generic publish-time overrides/metadata.""" + + spec = self.load_component_spec(component_path, annotations=annotations) + if name: + spec.name = name + spec.data["name"] = name + if description: + spec.description = description + spec.data["description"] = description + component_yaml_path = None + if self._git_root: + try: + component_yaml_path = str(Path(component_path).resolve().relative_to(Path(self._git_root).resolve())) + except ValueError: + pass + spec.update_fields( + git_remote_sha=self.git_remote_sha, + git_remote_branch=self.git_remote_branch, + git_remote_url=self.git_remote_url, + image=image, + component_yaml_path=component_yaml_path, + ) + return spec + + def deprecate_component( + self, + digest: str, + superseded_by: str | None = None, + ) -> dict[str, Any]: + """Deprecate a published component by digest.""" + + client = self._get_client() + if not client: + return { + "success": False, + "digest": digest, + "error": "Failed to create TangleApiClient", + } + + try: + result = client.published_components_update( + digest=digest, + deprecated=True, + superseded_by=superseded_by, + ) + self.log.info(f"✅ Deprecated component {digest[:16]}...") + if superseded_by: + self.log.info(f" Superseded by: {superseded_by[:16]}...") + + return { + "success": True, + "digest": digest, + "superseded_by": superseded_by, + "response": _to_plain(result), + } + except Exception as exc: + self.log.error(f"❌ Failed to deprecate component {digest[:16]}...: {exc}") + return { + "success": False, + "digest": digest, + "error": str(exc), + } + + def publish_component( + self, + file_path: str | Path, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: dict[str, str] | None = None, + ) -> ProcessingResult: + """Publish a component to the Tangle Component Library with version checking.""" + + try: + path = Path(file_path) + local_yaml_content = read_component_yaml_text(path) + except Exception as exc: + self.log.error(f"❌ Failed to read file {file_path}: {exc}") + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=None, + latest_version=None, + reason=f"Failed to read file {file_path}: {exc}", + ) + + try: + spec = self._component_spec_model().from_yaml(local_yaml_content, annotations=dict(annotations or {})) + if spec.version is None: + self.log.warn(" ⏭️ Skipping: Component version is required but not found in YAML") + return ProcessingResult( + outcome=ProcessingOutcome.SKIP, + local_version=None, + latest_version=None, + spec=spec, + reason="Component version is required but not found in YAML", + ) + except ValueError as exc: + self.log.error(f" ❌ {exc}") + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=None, + latest_version=None, + reason=str(exc), + ) + + if name: + spec.name = name + spec.data["name"] = name + if description: + spec.description = description + spec.data["description"] = description + + client = self._get_client() + if not client and not self.dry_run: + self.log.error("❌ Failed to create TangleApiClient") + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=None, + latest_version=None, + spec=spec, + reason="Failed to create TangleApiClient", + ) + + version_check_result = self.perform_version_check(spec=spec) + + if version_check_result.outcome == ProcessingOutcome.SKIP: + self.log.info(f" ⏭️ Skipping API publish: {version_check_result.reason}") + return version_check_result + if version_check_result.outcome == ProcessingOutcome.ERROR: + self.log.error(f" ❌ Cannot proceed due to error: {version_check_result.reason}") + return version_check_result + + component_yaml_path = None + if self._git_root: + try: + component_yaml_path = str(Path(file_path).resolve().relative_to(Path(self._git_root).resolve())) + except ValueError: + pass + + spec.update_fields( + self.git_remote_sha, + self.git_remote_branch, + git_remote_url=self.git_remote_url, + image=image, + component_yaml_path=component_yaml_path, + ) + + spec_annotations = (getattr(spec, "data", None) or {}).get("metadata", {}).get("annotations") + if self._git_root and spec_annotations: + utils.normalize_annotation_paths(Path(file_path), self._git_root, spec_annotations) + + local_yaml_content = spec.to_yaml() + + if self.dry_run: + self.log.info(f"[DRY-RUN] Would publish component: {spec.name}") + return ProcessingResult( + outcome=ProcessingOutcome.SUCCESS, + local_version=version_check_result.local_version, + latest_version=version_check_result.latest_version, + spec=spec, + reason=f"Dry-run: would publish {spec.name}", + response={"name": spec.name, "text": local_yaml_content}, + ) + + filter_by = self.published_by or self.current_user_id(client) + if not filter_by: + self.log.error( + "❌ Cannot determine current user — aborting to avoid deprecating components owned by others" + ) + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=version_check_result.local_version, + latest_version=version_check_result.latest_version, + spec=spec, + reason="Cannot determine current user for author filtering", + ) + existing_components = client.find_existing_components(spec.search_names, verbose=True, published_by=filter_by) + + try: + result = client.published_components_create(name=spec.name, text=local_yaml_content) + plain_result = _to_plain(result) + new_digest = plain_result.get("digest") if isinstance(plain_result, Mapping) else None + + if new_digest: + self.log.info(f"✅ Published: {spec.name} (digest: {str(new_digest)[:16]}...)") + self.deprecate_old_components(existing_components, str(new_digest)) + return ProcessingResult( + outcome=ProcessingOutcome.SUCCESS, + local_version=version_check_result.local_version, + latest_version=version_check_result.latest_version, + spec=spec, + reason=f"Successfully published with digest: {new_digest}", + digest=str(new_digest), + response=result, + ) + + self.log.warn("⚠️ Component published but no digest returned") + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=version_check_result.local_version, + latest_version=version_check_result.latest_version, + spec=spec, + reason="Component published but no digest returned", + response=result, + ) + except Exception as exc: + self.log.error(f"❌ Request failed: {exc}") + return ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=version_check_result.local_version, + latest_version=version_check_result.latest_version, + spec=spec, + reason=f"Request failed: {exc}", + ) + + def publish_components(self, components_config: list[dict[str, Any]]) -> int: + """Publish components with per-component configuration to the Tangle API.""" + + self.log.info("\n" + "=" * 60) + self.log.info(f"📤 Publishing {len(components_config)} component(s) to Tangle API") + self.log.info("=" * 60) + + batch_context = self._publish_context(batch_config=components_config) + self._run_hook("before_batch", components_config, context=batch_context) + all_results: list[tuple[str, ProcessingResult]] = [] + + for config in components_config: + component_path = config.get("component_path") + image = config.get("image") + custom_name = config.get("name") + custom_description = config.get("description") + custom_annotations = config.get("annotations") + + if not component_path: + self.log.error(f"\n❌ Error: Missing 'component_path' in configuration: {config}") + error_result = ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=None, + latest_version=None, + reason="Missing 'component_path' in configuration", + ) + all_results.append(("", error_result)) + self._run_hook( + "after_component", + "", + error_result, + context=self._publish_context( + batch_config=components_config, + component_config=config, + component_path="", + result=error_result, + results=all_results, + ), + ) + continue + + component_name = custom_name or Path(component_path).stem + self.log.info(f"\n📦 Publishing component: {component_name}") + self.log.info(f" Source: {component_path}") + if image: + self.log.info(f" Image: {image}") + if custom_name: + self.log.info(f" Custom name: {custom_name}") + if custom_description: + desc_preview = custom_description[:50] + ("..." if len(custom_description) > 50 else "") + self.log.info(f" Custom description: {desc_preview}") + if custom_annotations: + self.log.info(f" Custom annotations: {list(custom_annotations.keys())}") + + try: + result = self.publish_component( + component_path, + image=image, + name=custom_name, + description=custom_description, + annotations=custom_annotations, + ) + except Exception as exc: + result = ProcessingResult( + outcome=ProcessingOutcome.ERROR, + local_version=None, + latest_version=None, + reason=f"Unexpected error: {exc}", + ) + self.log.error(f" ❌ Unexpected error: {exc}") + all_results.append((str(component_path), result)) + self._run_hook( + "after_component", + str(component_path), + result, + context=self._publish_context( + batch_config=components_config, + component_config=config, + component_path=str(component_path), + result=result, + results=all_results, + ), + ) + + success_count = sum(1 for _, result in all_results if result.outcome == ProcessingOutcome.SUCCESS) + skip_count = sum(1 for _, result in all_results if result.outcome == ProcessingOutcome.SKIP) + error_count = sum(1 for _, result in all_results if result.outcome == ProcessingOutcome.ERROR) + + self.log.info("\n" + "=" * 60) + self.log.info("📊 Tangle API Publish Summary") + self.log.info("=" * 60) + self.log.info(f"Total components found: {len(all_results)}") + self.log.info(f"Successfully published: {success_count}") + self.log.info(f"Skipped (version check): {skip_count}") + self.log.info(f"Failed: {error_count}") + + error_results = [(path, result) for path, result in all_results if result.outcome == ProcessingOutcome.ERROR] + if error_results: + self.log.error("\n❌ Error details:") + for path, result in error_results: + component_name = result.spec.name if result.spec else Path(path).stem + self.log.error(f" • {component_name}: {result.reason}") + + self.results = all_results + self._run_hook( + "after_batch", + all_results, + context=self._publish_context(batch_config=components_config, results=all_results), + ) + + if len(all_results) == 0: + self.log.warn("\n⚠️ No components specified in configuration") + return 1 + if error_count > 0: + if error_count == len(all_results): + self.log.error("\n❌ All components failed to publish") + else: + self.log.error(f"\n❌ {error_count} component(s) failed to publish") + return 1 + return 0 + + def _publish_context( + self, + *, + batch_config: Sequence[Mapping[str, Any]] | None = None, + component_config: Mapping[str, Any] | None = None, + component_path: str | None = None, + result: ProcessingResult | None = None, + results: Sequence[tuple[str, ProcessingResult]] | None = None, + ) -> ComponentPublishContext: + return ComponentPublishContext( + publisher=self, + dry_run=self.dry_run, + git_remote_sha=self.git_remote_sha, + git_remote_branch=self.git_remote_branch, + git_remote_url=self.git_remote_url, + git_repo=self.git_repo, + git_root=self._git_root, + published_by=self.published_by, + batch_config=batch_config, + component_config=component_config, + component_path=component_path, + result=result, + results=tuple(results or ()), + ) + + def _run_hook(self, method_name: str, *args: Any, context: ComponentPublishContext | None = None) -> None: + for hook in self.hooks: + method = getattr(hook, method_name, None) + if not method: + continue + if context is not None and _hook_accepts_context(method): + method(*args, context=context) + else: + method(*args) + + +# ============================================================================ +# Internal helpers +# ============================================================================ + + +def read_component_yaml_text(component_path: str | Path) -> str: + """Read component YAML text from disk.""" + + return Path(component_path).read_text(encoding="utf-8") + + +def deprecate_old_components( + existing_components: Sequence[Any], + new_digest: str, + client: Any = None, + logger: Logger | None = None, +) -> int: + """Deprecate old versions of a component after publishing a new one.""" + + return ComponentPublisher(client=client, logger=logger).deprecate_old_components( + existing_components, + new_digest, + ) + + +def perform_version_check( + spec: Any, + dry_run: bool, + client: Any = None, + logger: Logger | None = None, + published_by: str | None = None, +) -> ProcessingResult: + """Perform owner-scoped version checking for a component.""" + + return ComponentPublisher( + dry_run=dry_run, + client=client, + logger=logger, + published_by=published_by, + ).perform_version_check(spec) + + +def publish_component_to_tangle( + file_path: str | Path, + dry_run: bool = False, + git_remote_sha: str | None = None, + git_remote_branch: str | None = None, + git_remote_url: str | None = None, + git_repo: str | None = None, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: dict[str, str] | None = None, + client: Any = None, + client_factory: Callable[[], Any] | None = None, + published_by: str | None = None, +) -> ProcessingResult: + """Publish one component using ``ComponentPublisher.publish_component``.""" + + publisher = ComponentPublisher( + dry_run=dry_run, + client=client, + client_factory=client_factory, + git_remote_sha=git_remote_sha, + git_remote_branch=git_remote_branch, + git_remote_url=git_remote_url, + git_repo=git_repo, + published_by=published_by, + ) + return publisher.publish_component( + file_path, + image=image, + name=name, + description=description, + annotations=annotations, + ) + + +def publish_component(client: Any, component_path: str | Path, **kwargs: Any) -> ProcessingResult: + """Compatibility wrapper around ``ComponentPublisher`` for one component.""" + + publisher = ComponentPublisher( + dry_run=bool(kwargs.pop("dry_run", False)), + git_remote_sha=kwargs.pop("git_remote_sha", None), + git_remote_branch=kwargs.pop("git_remote_branch", None), + git_remote_url=kwargs.pop("git_remote_url", None), + git_root=kwargs.pop("git_root", None), + git_repo=kwargs.pop("git_repo", None), + published_by=kwargs.pop("published_by", None), + client=client, + client_factory=kwargs.pop("client_factory", None), + logger=kwargs.pop("logger", None), + ) + return publisher.publish_component(component_path, **kwargs) + + +def deprecate_component( + client: Any, + digest: str, + *, + superseded_by: str | None = None, + logger: Logger | None = None, +) -> dict[str, Any]: + """Compatibility wrapper around ``ComponentPublisher.deprecate_component``.""" + + return ComponentPublisher(client=client, logger=logger).deprecate_component( + digest, + superseded_by=superseded_by, + ) + + +def prepare_component_for_publish( + component_path: str | Path, + *, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: Mapping[str, str] | None = None, + git_remote_sha: str | None = None, + git_remote_branch: str | None = None, + git_remote_url: str | None = None, + git_root: str | Path | None = None, +) -> "ComponentSpec": + """Load and apply generic publish-time overrides/metadata.""" + + return ComponentPublisher( + git_remote_sha=git_remote_sha, + git_remote_branch=git_remote_branch, + git_remote_url=git_remote_url, + git_root=git_root, + ).prepare_component_for_publish( + component_path, + image=image, + name=name, + description=description, + annotations=annotations, + ) + + +def _hook_accepts_context(method: Any) -> bool: + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + return False + for parameter in signature.parameters.values(): + if parameter.kind == inspect.Parameter.VAR_KEYWORD: + return True + if parameter.name == "context": + return True + return False + + +def _to_plain(value: Any) -> Any: + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "model_dump"): + return value.model_dump(by_alias=True, exclude_none=True) + if isinstance(value, Mapping): + return {key: _to_plain(item) for key, item in value.items()} + if isinstance(value, list): + return [_to_plain(item) for item in value] + return value + + +__all__ = [ + "ComponentPublishContext", + "ComponentPublishHook", + "ComponentPublisher", + "ProcessingOutcome", + "ProcessingResult", + "deprecate_component", + "deprecate_old_components", + "perform_version_check", + "prepare_component_for_publish", + "publish_component", + "publish_component_to_tangle", + "read_component_yaml_text", +] diff --git a/packages/tangle-cli/src/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py new file mode 100644 index 0000000..3fcf692 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -0,0 +1,269 @@ +import pathlib +import sys +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .cli_helpers import load_args_or_exit, optional_path +from .cli_options import ConfigOption, LogTypeOption +from .logger import logger_for_log_type + +app = App(name="components", help="Work with Tangle component definitions.") + +generate_app = App(name="generate", help="Generate component definition files.") +app.command(generate_app) + +component_references_app = App( + name="component-references", help="Work with component reference metadata." +) +app.command(component_references_app) + +annotations_app = App(name="annotations", help="Work with component annotations.") +app.command(annotations_app) + +# region components + + +@app.command(name="validate") +def components_validate(component_path: str): + raise NotImplementedError() + + +@app.command(name="set-container-image") +def components_set_container_image(component_path: str): + raise NotImplementedError() + + +# endregion + + +# region components/annotations + + +def _missing_required_args(command_name: str, provided: dict[str, object]) -> None: + """Print help for truly empty commands, but error on partial invocations.""" + + if all(value is None for value in provided.values()): + annotations_app.help_print([command_name]) + raise SystemExit(0) + + missing = [name for name, value in provided.items() if value is None] + print(f"Missing required argument(s): {', '.join(missing)}", file=sys.stderr) + raise SystemExit(1) + + +@annotations_app.command(name="set") +def components_annotations_set( + component_path: str | None = None, + key: str | None = None, + value: str | None = None, + output_component_path: str | None = None, +): + """Sets annotation value in component file.""" + if component_path is None or key is None or value is None: + _missing_required_args( + "set", + {"component_path": component_path, "key": key, "value": value}, + ) + raise NotImplementedError() + + +@annotations_app.command(name="get") +def components_annotations_get( + component_path: str | None = None, keys: list[str] | None = None +): + """Gets annotation values from component file.""" + if component_path is None or keys is None: + _missing_required_args("get", {"component_path": component_path, "keys": keys}) + raise NotImplementedError() + + +# endregion + + +# region components/generate + + +@generate_app.command(name="from-template", show=False) +def components_generate_from_template( + template_name: str, + output_component_path: pathlib.Path, +): + raise NotImplementedError() + + +def _components_generate_from_python_impl( + *, + python_file: pathlib.Path | None = None, + output: pathlib.Path | None = None, + name: str | None = None, + function_name: str | None = None, + image: str | None = None, + dependencies_from: pathlib.Path | None = None, + strip_code: bool | None = None, + use_legacy_naming: bool | None = None, + mode: str | None = None, + resolve_root: pathlib.Path | None = None, + config: str | None = None, + log_type: str = "console", +) -> None: + all_args = load_args_or_exit( + config, + python_file=("python_file", python_file, None, False, True, optional_path), + output=(output, None, optional_path), + name=(name, None), + function_name=("function", function_name, None, False), + image=(image, None), + dependencies_from=(dependencies_from, None, optional_path), + strip_code=(strip_code, None), + use_legacy_naming=(use_legacy_naming, None), + mode=(mode, None), + resolve_root=(resolve_root, None, optional_path), + log_type=(log_type, "console"), + ) + for args in all_args: + logger, finalize_logs = logger_for_log_type(args.log_type) + from .component_generator import ComponentGenerator + + generator = ComponentGenerator(logger=logger, verbose=True) + selected_mode = args.mode or "inline" + if selected_mode not in {"inline", "bundle"}: + raise SystemExit("--mode must be 'inline' or 'bundle'") + python_path = pathlib.Path(args.python_file) + output_path = generator.determine_output_path( + python_path, + args.output, + output_is_dir=False, + use_legacy_naming=bool(args.use_legacy_naming), + ) + try: + success = generator.regenerate_yaml( + python_file=python_path, + output_path=output_path, + function_name=args.function_name, + custom_name=args.name, + image=args.image, + dependencies_from=args.dependencies_from, + strip_code=bool(args.strip_code), + mode=selected_mode, + resolve_root=args.resolve_root, + ) + if not success: + raise SystemExit(1) + finally: + finalize_logs() + + +@generate_app.command(name="from-python") +def components_generate_from_python( + python_file: pathlib.Path | None = None, + *, + output: pathlib.Path | None = None, + name: str | None = None, + function_name: Annotated[ + str | None, + Parameter(name="--function", alias="-f", help="Function name to extract."), + ] = None, + image: str | None = None, + dependencies_from: pathlib.Path | None = None, + strip_code: bool | None = None, + use_legacy_naming: bool | None = None, + mode: str | None = None, + resolve_root: pathlib.Path | None = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Generate a component YAML file from a local Python function.""" + + _components_generate_from_python_impl( + python_file=python_file, + output=output, + name=name, + function_name=function_name, + image=image, + dependencies_from=dependencies_from, + strip_code=strip_code, + use_legacy_naming=use_legacy_naming, + mode=mode, + resolve_root=resolve_root, + config=config, + log_type=log_type, + ) + + +@generate_app.command(name="from-python-function") +def components_generate_from_python_function( + python_file: pathlib.Path | None = None, + *, + output: pathlib.Path | None = None, + name: str | None = None, + function_name: Annotated[ + str | None, + Parameter(name="--function", alias="-f", help="Function name to extract."), + ] = None, + image: str | None = None, + dependencies_from: pathlib.Path | None = None, + strip_code: bool | None = None, + use_legacy_naming: bool | None = None, + mode: str | None = None, + resolve_root: pathlib.Path | None = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Compatibility alias for `generate from-python`.""" + + _components_generate_from_python_impl( + python_file=python_file, + output=output, + name=name, + function_name=function_name, + image=image, + dependencies_from=dependencies_from, + strip_code=strip_code, + use_legacy_naming=use_legacy_naming, + mode=mode, + resolve_root=resolve_root, + config=config, + log_type=log_type, + ) + + +# endregion + + +@app.command(name="bump-version") +def components_bump_version( + yaml_file: pathlib.Path | None = None, + *, + set_version: str | None = None, + update_timestamp: bool | None = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Bump version metadata in a component YAML file.""" + + all_args = load_args_or_exit( + config, + yaml_file=("yaml_file", yaml_file, None, False, True, optional_path), + set_version=(set_version, None), + update_timestamp=(update_timestamp, None), + log_type=(log_type, "console"), + ) + result: dict[str, Any] = {} + from .version_manager import bump_version + + for args in all_args: + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + result = bump_version( + args.yaml_file, + set_version=args.set_version, + update_timestamp=bool(args.update_timestamp), + logger=logger, + ) + if result.get("status") != "success": + raise SystemExit(1) + finally: + finalize_logs() + if result: + print(result) diff --git a/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py b/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py new file mode 100644 index 0000000..d3a5582 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py @@ -0,0 +1,296 @@ +"""Programmatic dynamic-discovery client for Tangle backends.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx + +from .api_schema import ( + OperationCommand, + fetch_schema, + load_cached_schema, + operation_aliases, + operation_map, + refresh_schema, + resolve_operation, +) +from .api_transport import ( + DEFAULT_TIMEOUT_SECONDS, + _normalize_base_url, + default_base_url, + request_operation, +) + + +class TangleDynamicDiscoveryClient: + """Dynamic-discovery client generated from a Tangle OpenAPI schema. + + The client intentionally reuses the same schema cache, operation naming, + parameter mapping, URL construction, and auth/header handling as + ``tangle api ...``. No network or cache work happens at import time; choose + one of the ``from_*`` constructors to provide or load a schema. + """ + + def __init__( + self, + schema: dict[str, Any], + *, + base_url: str | None = None, + headers: dict[str, str] | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> None: + self.schema = schema + self.base_url = _normalize_base_url(base_url or default_base_url()) + self.headers = dict(headers or {}) + self.token = token + self.auth_header = auth_header + self.header = _header_list(header) + self.timeout = timeout + self._operations = operation_map(schema) + self._aliases = self._build_alias_map(self._operations) + self._groups = self._build_groups(self._operations) + + @classmethod + def from_schema( + cls, + schema: dict[str, Any], + *, + base_url: str | None = None, + headers: dict[str, str] | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> TangleDynamicDiscoveryClient: + """Create a client from an already loaded OpenAPI schema.""" + + return cls( + schema, + base_url=base_url, + headers=headers, + token=token, + auth_header=auth_header, + header=header, + timeout=timeout, + ) + + @classmethod + def from_cache( + cls, + base_url: str | None = None, + *, + headers: dict[str, str] | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> TangleDynamicDiscoveryClient: + """Create a client from the local schema cache without network access.""" + + normalized_base_url = _normalize_base_url(base_url or default_base_url()) + schema = load_cached_schema(normalized_base_url) + if schema is None: + raise FileNotFoundError( + f"No cached OpenAPI schema for {normalized_base_url}; " + "call TangleDynamicDiscoveryClient.from_cache_or_refresh(...) or run `tangle api refresh`." + ) + return cls.from_schema( + schema, + base_url=normalized_base_url, + headers=headers, + token=token, + auth_header=auth_header, + header=header, + timeout=timeout, + ) + + @classmethod + def from_url( + cls, + base_url: str | None = None, + *, + headers: dict[str, str] | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> TangleDynamicDiscoveryClient: + """Fetch ``/openapi.json`` and create a client without writing the cache.""" + + normalized_base_url = _normalize_base_url(base_url or default_base_url()) + schema = fetch_schema( + normalized_base_url, + token=token, + header=header, + auth_header=auth_header, + headers=headers, + ) + return cls.from_schema( + schema, + base_url=normalized_base_url, + headers=headers, + token=token, + auth_header=auth_header, + header=header, + timeout=timeout, + ) + + @classmethod + def from_cache_or_refresh( + cls, + base_url: str | None = None, + *, + headers: dict[str, str] | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> TangleDynamicDiscoveryClient: + """Create a client from cache, fetching and caching the schema on miss.""" + + normalized_base_url = _normalize_base_url(base_url or default_base_url()) + schema = load_cached_schema(normalized_base_url) + if schema is None: + schema, _ = refresh_schema( + normalized_base_url, + token=token, + header=header, + auth_header=auth_header, + headers=headers, + ) + return cls.from_schema( + schema, + base_url=normalized_base_url, + headers=headers, + token=token, + auth_header=auth_header, + header=header, + timeout=timeout, + ) + + @property + def operations(self) -> tuple[str, ...]: + """Canonical operation names exposed by this schema.""" + + return tuple(sorted(self._operations)) + + def request(self, operation_name: str, **params: Any) -> httpx.Response: + """Perform an operation and return the raw ``httpx.Response``. + + Operation parameters are passed as keyword arguments. Per-call overrides + for ``base_url``, ``token``, ``auth_header``, ``header``, ``headers``, + ``body``, and ``timeout`` are also supported. + """ + + operation = self._resolve(operation_name) + base_url = params.pop("base_url", self.base_url) + token = params.pop("token", self.token) + auth_header = params.pop("auth_header", self.auth_header) + header_override = params.pop("header", None) + header = self.header + _header_list(header_override) + headers_override = params.pop("headers", None) + headers = {**self.headers, **dict(headers_override or {})} + body = params.pop("body", None) + timeout = params.pop("timeout", self.timeout) + return request_operation( + operation, + params, + base_url=base_url, + token=token, + auth_header=auth_header, + header_entries=header, + headers=headers, + body=body, + timeout=timeout, + ) + + def call(self, operation_name: str, **params: Any) -> Any: + """Perform an operation and decode the response body. + + JSON responses return decoded JSON; text responses return ``str``; + non-text responses return ``bytes``; empty responses return ``None``. + """ + + return decode_response(self.request(operation_name, **params)) + + def __getattr__(self, name: str) -> _OperationGroup: + canonical_group = self._groups.get(name) or self._groups.get(name.replace("_", "-")) + if canonical_group is None: + raise AttributeError(name) + return _OperationGroup(self, canonical_group) + + def _resolve(self, operation_name: str) -> OperationCommand: + return self._aliases.get(operation_name) or resolve_operation(self._operations, operation_name) + + @staticmethod + def _build_alias_map( + operations: dict[str, OperationCommand] + ) -> dict[str, OperationCommand]: + aliases: dict[str, OperationCommand] = {} + for name, operation in operations.items(): + for alias in operation_aliases(name): + aliases.setdefault(alias, operation) + return aliases + + @staticmethod + def _build_groups( + operations: dict[str, OperationCommand] + ) -> dict[str, str]: + groups: dict[str, str] = {} + for operation in operations.values(): + groups.setdefault(operation.group_name, operation.group_name) + groups.setdefault(operation.group_name.replace("-", "_"), operation.group_name) + return groups + + +class _OperationGroup: + """Dynamic operation namespace returned by ``client.``.""" + + def __init__(self, client: TangleDynamicDiscoveryClient, group_name: str) -> None: + self._client = client + self._group_name = group_name + + def call(self, command_name: str, **params: Any) -> Any: + return self._client.call(f"{self._group_name}.{command_name}", **params) + + def request(self, command_name: str, **params: Any) -> httpx.Response: + return self._client.request(f"{self._group_name}.{command_name}", **params) + + def __getattr__(self, name: str) -> Callable[..., Any]: + operation_name = f"{self._group_name}.{name}" + try: + self._client._resolve(operation_name) + except KeyError as exc: + raise AttributeError(name) from exc + + def operation(**params: Any) -> Any: + return self._client.call(operation_name, **params) + + operation.__name__ = name + return operation + + +def _header_list(header: list[str] | str | None) -> list[str]: + if header is None: + return [] + if isinstance(header, str): + return [header] + return list(header) + + +def decode_response(response: httpx.Response) -> Any: + """Decode an ``httpx.Response`` into JSON, text, bytes, or ``None``.""" + + if not response.content: + return None + content_type = response.headers.get("Content-Type", "").lower() + if "json" in content_type: + return response.json() + if content_type.startswith("text/") or "charset=" in content_type: + return response.text + return response.content diff --git a/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py b/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py new file mode 100644 index 0000000..63743e4 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py @@ -0,0 +1,405 @@ +"""Handwritten extensions mixed into generated Tangle API models.""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, cast + +import yaml + +import tangle_cli.utils as utils + + +def _strip_text_from_graph(implementation: dict[str, Any]) -> None: + """Recursively remove raw component text from graph component references.""" + + graph = implementation.get("graph", {}) + for task_data in graph.get("tasks", {}).values(): + ref = task_data.get("componentRef") + if not ref: + continue + ref.pop("text", None) + spec = ref.get("spec", {}) + nested_impl = spec.get("implementation") + if nested_impl and "graph" in nested_impl: + _strip_text_from_graph(nested_impl) + + +def _add_official_prefix(name: str) -> str: + """Return the official component name variant used by registry searches.""" + + if name and not name.startswith("[Official]"): + return f"[Official] {name}" + return name + + +class ComponentSpecExtensions: + """Legacy YAML-domain conveniences for the generated ComponentSpec model.""" + + _STRIP_ANNOTATION_KEYS = {"python_original_code", "python_dependencies"} + + def _extra_get(self, key: str, default: Any = None) -> Any: + extra = getattr(self, "__pydantic_extra__", None) + if isinstance(extra, dict) and key in extra: + return extra[key] + return getattr(self, "__dict__", {}).get(key, default) + + def _extra_set(self, key: str, value: Any) -> None: + extra = getattr(self, "__pydantic_extra__", None) + if isinstance(extra, dict): + extra[key] = value + else: # pragma: no cover - pydantic v1 fallback + self.__dict__[key] = value + + @property + def data(self) -> dict[str, Any]: + data = self._extra_get("data") + if isinstance(data, dict): + return data + result: dict[str, Any] = {} + if self.name: + result["name"] = self.name + if self.description is not None: + result["description"] = self.description + if self.metadata: + result["metadata"] = self.metadata + if self.inputs: + result["inputs"] = self.inputs + if self.outputs: + result["outputs"] = self.outputs + if self.implementation: + result["implementation"] = self.implementation + return result + + @data.setter + def data(self, value: dict[str, Any]) -> None: + self._extra_set("data", value) + + @property + def digest(self) -> str: + return str(self._extra_get("digest", "") or "") + + @digest.setter + def digest(self, value: str) -> None: + self._extra_set("digest", value) + + @property + def text(self) -> str | None: + return self._extra_get("text") + + @text.setter + def text(self, value: str | None) -> None: + self._extra_set("text", value) + + @property + def version(self) -> str | None: + return self._extra_get("version") + + @version.setter + def version(self, value: str | None) -> None: + self._extra_set("version", value) + + @property + def annotations(self) -> dict[str, str]: + annotations = self._extra_get("annotations") + if isinstance(annotations, dict): + return annotations + return (self.metadata or {}).get("annotations", {}) + + @annotations.setter + def annotations(self, value: dict[str, str]) -> None: + self._extra_set("annotations", value) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Any: + """Create from a raw component API response. + + ``/api/components/{digest}`` responses carry raw YAML in ``text`` and + may carry the parsed YAML in ``spec``. The generated model stores the + schema fields while extra fields preserve legacy helpers such as + ``data``, ``digest``, ``text``, ``version``, and ``annotations``. + """ + + spec = data.get("spec") + text = data.get("text") + if spec is None and text: + spec = yaml.safe_load(text) + if spec is None and any( + key in data + for key in ("name", "description", "metadata", "inputs", "outputs", "implementation") + ): + spec = data + spec = spec or {} + annotations = spec.get("metadata", {}).get("annotations", {}) + return cls( + digest=data.get("digest", ""), + data=spec, + text=text, + name=spec.get("name", ""), + version=annotations.get("version"), + description=spec.get("description"), + annotations=annotations, + inputs=spec.get("inputs", []), + outputs=spec.get("outputs", []), + implementation=spec.get("implementation"), + metadata=spec.get("metadata"), + ) + + @classmethod + def from_yaml_file(cls, yaml_path: str) -> Any: + """Load and parse a component YAML file.""" + + with open(yaml_path) as f: + yaml_content = f.read() + return cls.from_yaml(yaml_content) + + @classmethod + def from_yaml( + cls, + yaml_content: str, + annotations: dict[str, str] | None = None, + ) -> Any: + """Create from YAML text, optionally merging annotations first.""" + + data = utils.parse_yaml_string(yaml_content) + if not data: + raise ValueError("Unable to parse YAML content") + + if annotations: + data.setdefault("metadata", {}).setdefault("annotations", {}).update(annotations) + + name = data.get("name") + if not name: + raise ValueError("Component name is required but not found in YAML") + + version = utils.get_version_from_data(data) or None + return cls( + data=data, + version=version, + name=name, + description=data.get("description"), + text=yaml_content, + annotations=data.get("metadata", {}).get("annotations", {}), + inputs=data.get("inputs", []), + outputs=data.get("outputs", []), + implementation=data.get("implementation"), + metadata=data.get("metadata"), + ) + + @classmethod + def from_spec(cls, spec: dict[str, Any]) -> Any: + """Create from an inline component spec dict.""" + + annotations = spec.get("metadata", {}).get("annotations", {}) + return cls( + data=spec, + name=spec.get("name", ""), + description=spec.get("description"), + annotations=annotations, + inputs=spec.get("inputs", []), + outputs=spec.get("outputs", []), + implementation=spec.get("implementation"), + metadata=spec.get("metadata"), + ) + + def __bool__(self) -> bool: + return bool(getattr(self, "data", None)) + + @property + def search_names(self) -> list[str]: + """Names to use for searching, including the official-name variant.""" + + name = getattr(self, "name", "") or "" + return [name, _add_official_prefix(name)] + + @property + def stripped_spec(self) -> dict[str, Any] | None: + """Component data with bulky annotations and implementation removed.""" + + data = getattr(self, "data", None) + if not data: + return None + result = dict(data) + result.pop("implementation", None) + annotations = result.get("metadata", {}).get("annotations", {}) + if annotations: + result["metadata"] = dict(result["metadata"]) + result["metadata"]["annotations"] = { + key: value + for key, value in annotations.items() + if key not in self._STRIP_ANNOTATION_KEYS + } + return result + + def strip_implementation(self, *, keep_graph: bool = False) -> None: + """Remove implementation details in-place.""" + + self.text = None + if keep_graph: + if self.implementation: + _strip_text_from_graph(self.implementation) + else: + self.implementation = None + self.data.pop("implementation", None) + + def to_yaml(self) -> str: + """Convert component data back to YAML.""" + + return utils.dump_yaml(self.data) + + def save_to_file(self, file_path: str) -> None: + """Write component data to a YAML file.""" + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w") as f: + f.write(self.to_yaml()) + + def update_fields( + self, + git_remote_sha: str | None = None, + git_remote_branch: str | None = None, + git_remote_url: str | None = None, + image: str | None = None, + component_yaml_path: str | None = None, + ) -> Any: + """Update publishing metadata in-place and return ``self``.""" + + self.data.setdefault("metadata", {}).setdefault("annotations", {}) + annotations = self.data["metadata"]["annotations"] + annotations["published_at"] = datetime.now(timezone.utc).isoformat() + + if git_remote_sha: + annotations.setdefault("git_remote_sha", git_remote_sha) + if git_remote_branch: + annotations.setdefault("git_remote_branch", git_remote_branch) + if git_remote_url: + annotations.setdefault("git_remote_url", git_remote_url) + if component_yaml_path: + utils.set_component_yaml_path(component_yaml_path, annotations, overwrite=False) + + if "version" in self.data: + annotations["version"] = str(self.data.pop("version")) + if "updated_at" in self.data: + annotations["updated_at"] = str(self.data.pop("updated_at")) + + if image: + self.data.setdefault("implementation", {}).setdefault("container", {})["image"] = image + self.implementation = self.data["implementation"] + self.metadata = self.data.get("metadata") + self.annotations = annotations + return self + + def fetch_from_url(self, url: str, timeout: int = 10) -> bool: + """Fetch and parse component YAML from a URL into this model.""" + + import httpx + + try: + response = httpx.get(url, timeout=timeout) + response.raise_for_status() + self.text = response.text + self.data = yaml.safe_load(response.text) + self.name = self.data.get("name", "") + self.description = self.data.get("description") + self.metadata = self.data.get("metadata") + self.annotations = self.data.get("metadata", {}).get("annotations", {}) + self.inputs = self.data.get("inputs", []) + self.outputs = self.data.get("outputs", []) + self.implementation = self.data.get("implementation") + self.version = self.annotations.get("version") + return True + except Exception: + return False + + def ensure_digest(self) -> str | None: + """Compute and store a digest if one is not already present.""" + + if getattr(self, "digest", None): + return self.digest + from tangle_cli.utils import compute_spec_digest, compute_text_digest + + if getattr(self, "text", None): + self.digest = compute_text_digest(self.text) + elif getattr(self, "data", None): + self.digest = compute_spec_digest(self.data) + return self.digest or None + + + +class GetExecutionInfoResponseExtensions: + """Legacy execution-detail conveniences for generated execution responses.""" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Any: + """Create a normalized execution details model from API response data.""" + + from tangle_cli.models import TaskSpec + + return cls( + id=data.get("id", ""), + task_spec=TaskSpec.from_dict(data.get("task_spec", {})), + pipeline_run_id=data.get("pipeline_run_id"), + parent_execution_id=data.get("parent_execution_id"), + child_task_execution_ids=data.get("child_task_execution_ids"), + input_artifacts={ + key: value["id"] + for key, value in data.get("input_artifacts", {}).items() + if "id" in value + }, + output_artifacts={ + key: value["id"] + for key, value in data.get("output_artifacts", {}).items() + if "id" in value + }, + child_executions={}, + raw=data, + ) + + def strip_implementations(self) -> None: + """Remove implementation blocks from this execution tree in-place.""" + + self.task_spec.strip_implementations() + for child in self.child_executions.values(): + child.strip_implementations() + + @property + def tasks(self) -> dict[str, Any]: + """Shortcut to the root task spec's graph tasks.""" + + return self.task_spec.graph_tasks + + +class GetGraphExecutionStateResponseExtensions: + """Convenience properties for graph execution state responses.""" + + @property + def per_execution(self) -> dict[str, dict[str, int]]: + return cast( + dict[str, dict[str, int]], + getattr(self, "child_execution_status_stats", None) or {}, + ) + + @property + def status_totals(self) -> dict[str, int]: + totals: dict[str, int] = {} + for status_counts in self.per_execution.values(): + for status, count in status_counts.items(): + totals[status] = totals.get(status, 0) + count + return totals + + @property + def failed_execution_ids(self) -> list[str]: + return [ + execution_id + for execution_id, status_counts in self.per_execution.items() + if status_counts.get("FAILED", 0) > 0 + or status_counts.get("SYSTEM_ERROR", 0) > 0 + ] + + +MODEL_EXTENSIONS = { + "ComponentSpec": "ComponentSpecExtensions", + "GetExecutionInfoResponse": "GetExecutionInfoResponseExtensions", + "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", +} diff --git a/packages/tangle-cli/src/tangle_cli/generated_runtime.py b/packages/tangle-cli/src/tangle_cli/generated_runtime.py new file mode 100644 index 0000000..b052b35 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/generated_runtime.py @@ -0,0 +1,43 @@ +"""Runtime helpers shared by generated Tangle API model packages.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +try: + from pydantic import ConfigDict +except ImportError: # pragma: no cover - pydantic v1 fallback + ConfigDict = None # type: ignore[assignment] + + +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 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 to_dict(self) -> dict[str, Any]: + if hasattr(self, "model_dump"): + return self.model_dump(by_alias=True) + return self.dict(by_alias=True) + + @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) + + +__all__ = ["TangleGeneratedModel"] diff --git a/packages/tangle-cli/src/tangle_cli/handler.py b/packages/tangle-cli/src/tangle_cli/handler.py new file mode 100644 index 0000000..df50c9d --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/handler.py @@ -0,0 +1,96 @@ +"""Shared base classes for Tangle CLI service handlers.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +from .api_transport import default_base_url +from .logger import Logger, get_default_logger + + +def _client_base_url_without_materializing(client: Any | None) -> Any | None: + """Return a client's configured base URL without triggering lazy proxies.""" + + if client is None: + return None + + try: + client_vars = object.__getattribute__(client, "__dict__") + except AttributeError: + client_vars = {} + if isinstance(client_vars, Mapping): + if client_vars.get("base_url"): + return client_vars["base_url"] + client_kwargs = client_vars.get("client_kwargs") + if isinstance(client_kwargs, Mapping): + return client_kwargs.get("base_url") + + try: + return object.__getattribute__(client, "base_url") + except AttributeError: + return None + + +class TangleCliHandler: + """Base class for CLI/services that use logging and lazy Tangle API clients.""" + + _required_client_error_type: type[Exception] = RuntimeError + _required_client_error_message = "Failed to create TangleApiClient" + + def __init__( + self, + *, + dry_run: bool = False, + client: Any = None, + client_factory: Callable[[], Any] | None = None, + logger: Logger | None = None, + base_url: str | None = None, + ) -> None: + self.dry_run = dry_run + client_base_url = _client_base_url_without_materializing(client) + self.base_url = str(base_url or client_base_url or default_base_url()) + self.client = client + self._client = client + self._client_factory = client_factory + self.log = logger or get_default_logger() + + def _create_client(self) -> Any | None: + """Create the default OSS Tangle API client.""" + + try: + from .client import TangleApiClient + except ModuleNotFoundError as exc: + 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." + ) + return None + raise + return TangleApiClient(logger=self.log) + + def _set_client(self, client: Any | None) -> Any | None: + self.client = client + self._client = client + client_base_url = _client_base_url_without_materializing(client) + if client_base_url: + self.base_url = str(client_base_url) + return client + + def _get_client(self) -> Any | None: + """Get or lazily create a Tangle API client instance.""" + + if self._client is None and not self.dry_run: + if self._client_factory is not None: + return self._set_client(self._client_factory()) + return self._set_client(self._create_client()) + return self._client + + def _require_client(self) -> Any: + """Return a Tangle API client, raising if one cannot be created.""" + + client = self._get_client() + if client is None: + raise self._required_client_error_type(self._required_client_error_message) + return client diff --git a/packages/tangle-cli/src/tangle_cli/hydration_trust.py b/packages/tangle-cli/src/tangle_cli/hydration_trust.py new file mode 100644 index 0000000..f4ace41 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/hydration_trust.py @@ -0,0 +1,222 @@ +"""Trust controls for hydration features that can execute local Python code.""" + +from __future__ import annotations + +import os +from fnmatch import fnmatchcase +from pathlib import Path +from typing import Iterable + +import yaml + +_TRUSTED_PYTHON_SOURCES: list[str] = [] +_ALLOW_ALL_HYDRATION = False +_PACKAGE_CONFIG = Path(__file__).with_name("trusted_hydration.yaml") +_USER_CONFIGS = ( + Path.home() / ".config" / "tangle" / "trusted_hydration.yaml", + Path.home() / ".tangle" / "trusted_hydration.yaml", +) +_GLOB_CHARS = set("*?[") + + +def register_trusted_python_source(source: str | os.PathLike[str]) -> None: + """Register a trusted Python-source root or glob pattern. + + Sources are matched against canonical resolved paths. A non-glob source + trusts the exact file if it resolves to a file (or ends in ``.py``), and a + directory subtree otherwise. Glob sources are resolved up to their first + glob segment and matched against resolved candidate paths. + """ + + text = str(source).strip() + if text: + _TRUSTED_PYTHON_SOURCES.append(text) + + +def set_allow_all_hydration(allow: bool = True) -> None: + """Set a process-wide escape hatch for trusted hydration execution.""" + + global _ALLOW_ALL_HYDRATION + _ALLOW_ALL_HYDRATION = bool(allow) + + +def is_trusted_python_source( + path: str | os.PathLike[str], + *, + base_dirs: Iterable[str | os.PathLike[str] | None] | None = None, + trusted_sources: Iterable[str | os.PathLike[str]] | None = None, + allow_all: bool = False, +) -> bool: + """Return whether *path* may be executed for ``local_from_python``. + + The candidate path and every root/pattern prefix are canonicalized with + :meth:`Path.resolve` before matching so ``..`` traversal and symlink escapes + cannot extend trust outside the intended boundary. + """ + + if allow_all or _ALLOW_ALL_HYDRATION or _env_allow_all(): + return True + + candidate = _canonical(path) + if candidate is None: + return False + + for base_dir in base_dirs or (): + if base_dir and _is_within(candidate, _canonical(base_dir)): + return True + + for source in _all_configured_sources(trusted_sources): + if _matches_source(candidate, str(source)): + return True + + return False + + +def configured_trusted_python_sources( + extra_sources: Iterable[str | os.PathLike[str]] | None = None, +) -> list[str]: + """Return trusted Python-source patterns from registry/config/env/extras.""" + + return [str(source) for source in _all_configured_sources(extra_sources)] + + +def _all_configured_sources( + extra_sources: Iterable[str | os.PathLike[str]] | None = None, +) -> list[str]: + sources: list[str] = [] + sources.extend(_TRUSTED_PYTHON_SOURCES) + sources.extend(_load_configured_sources()) + sources.extend(_env_trusted_sources()) + if extra_sources: + sources.extend(str(source) for source in extra_sources if str(source).strip()) + return sources + + +def _env_allow_all() -> bool: + value = os.environ.get("TANGLE_TRUSTED_HYDRATION_ALLOW_ALL") + return bool(value and value.strip().lower() in {"1", "true", "yes", "on"}) + + +def _env_trusted_sources() -> list[str]: + value = os.environ.get("TANGLE_TRUSTED_PYTHON_SOURCES", "") + if not value.strip(): + return [] + parts: list[str] = [] + for chunk in value.replace(",", os.pathsep).split(os.pathsep): + chunk = chunk.strip() + if chunk: + parts.append(chunk) + return parts + + +def _load_configured_sources() -> list[str]: + sources: list[str] = [] + for path in _config_paths(): + sources.extend(_load_sources_from_file(path)) + return sources + + +def _config_paths() -> list[Path]: + paths = [_PACKAGE_CONFIG, *_USER_CONFIGS] + override = os.environ.get("TANGLE_TRUSTED_HYDRATION_CONFIG") + if override: + paths.append(Path(override).expanduser()) + return paths + + +def _load_sources_from_file(path: Path) -> list[str]: + if not path.exists(): + return [] + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return [] + trusted = data.get("trusted_hydration", data) if isinstance(data, dict) else data + if not isinstance(trusted, dict): + return [] + raw = trusted.get("trusted_python_sources", []) + if isinstance(raw, str): + return [raw] + if isinstance(raw, list): + return [str(item) for item in raw if str(item).strip()] + return [] + + +def _canonical(path: str | os.PathLike[str] | None) -> Path | None: + if path is None: + return None + try: + return Path(path).expanduser().resolve() + except OSError: + return None + + +def _is_within(candidate: Path, root: Path | None) -> bool: + if root is None: + return False + return candidate == root or root in candidate.parents + + +def _matches_source(candidate: Path, source: str) -> bool: + source = os.path.expandvars(source.strip()) + if not source: + return False + if any(char in source for char in _GLOB_CHARS): + return _matches_glob_source(candidate, source) + root = _canonical(source) + if root is None: + return False + source_path = Path(source) + if root.is_file() or source_path.suffix == ".py": + return candidate == root + return _is_within(candidate, root) + + +def _matches_glob_source(candidate: Path, source: str) -> bool: + raw = Path(source).expanduser() + parts = raw.parts + first_glob = next( + (index for index, part in enumerate(parts) if any(char in part for char in _GLOB_CHARS)), + None, + ) + if first_glob is None: + return _matches_source(candidate, source) + prefix_parts = parts[:first_glob] + suffix_parts = parts[first_glob:] + if prefix_parts: + prefix = Path(*prefix_parts).resolve() + else: + prefix = Path.cwd().resolve() + try: + relative_candidate = candidate.relative_to(prefix) + except ValueError: + return False + return _match_path_parts(relative_candidate.parts, suffix_parts) + + +def _match_path_parts(candidate_parts: tuple[str, ...], pattern_parts: tuple[str, ...]) -> bool: + if not pattern_parts: + return not candidate_parts + pattern = pattern_parts[0] + remaining_patterns = pattern_parts[1:] + if pattern == "**": + return any( + _match_path_parts(candidate_parts[index:], remaining_patterns) + for index in range(len(candidate_parts) + 1) + ) + if not candidate_parts: + return False + return fnmatchcase(candidate_parts[0], pattern) and _match_path_parts(candidate_parts[1:], remaining_patterns) + + +def trusted_python_source_guidance(path: str | os.PathLike[str]) -> str: + """Human-readable refusal guidance for an untrusted Python source.""" + + return ( + f"Refusing to execute untrusted local_from_python source {Path(path).expanduser()}. " + "Add an allowlisted trusted Python source with --trusted-source, " + "trusted_hydration.trusted_python_sources in config, " + "TANGLE_TRUSTED_PYTHON_SOURCES, or register_trusted_python_source(); " + "or use --trusted-hydration / set_allow_all_hydration() for trusted inputs. " + "You can also pre-hydrate trusted specs and submit them with --no-hydrate." + ) diff --git a/packages/tangle-cli/src/tangle_cli/logger.py b/packages/tangle-cli/src/tangle_cli/logger.py new file mode 100644 index 0000000..d6b1d03 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/logger.py @@ -0,0 +1,166 @@ +"""Structured logging for tangle-cli. + +Provides an injectable logger abstraction so library code never calls print() +directly. CLI entry points use the default :class:`ConsoleLogger` (same +behaviour as bare ``print``). Wrappers that need to capture output (an MCP +server, a test harness, etc.) inject a :class:`CaptureLogger` that +accumulates messages in memory and returns them as a single string. + +CLI commands use :func:`run_with_logging` to handle the ``--log-type`` flag +uniformly:: + + run_with_logging(log_type, lambda logger: my_core_func(..., logger=logger)) +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from typing import Any, Callable, Protocol + + +class Logger(Protocol): + """Minimal logging protocol for Tangle tooling.""" + + def info(self, msg: str) -> None: ... + def warn(self, msg: str) -> None: ... + def error(self, msg: str) -> None: ... + + +class ConsoleLogger: + """Default logger — prints to stderr so structured output on stdout stays clean.""" + + def info(self, msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + def warn(self, msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + def error(self, msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + +class CaptureLogger: + """Logger for MCP: accumulates messages in memory. + + Use :meth:`get_logs` to retrieve the collected output as a single string. + """ + + def __init__(self) -> None: + self._messages: list[str] = [] + + def info(self, msg: str) -> None: + self._messages.append(msg) + + def warn(self, msg: str) -> None: + self._messages.append(msg) + + def error(self, msg: str) -> None: + self._messages.append(f"[error] {msg}") + + def get_logs(self) -> str | None: + """Return accumulated logs as a single string, or None if empty.""" + text = "\n".join(self._messages).strip() + return text if text else None + + +class NullLogger: + """Logger that discards all messages. Used by MCP when include_logs is False.""" + + def info(self, msg: str) -> None: + pass + + def warn(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + +_default_logger = ConsoleLogger() +_null_logger = NullLogger() + + +def get_default_logger() -> ConsoleLogger: + """Return the module-level default :class:`ConsoleLogger`.""" + return _default_logger + + +# Valid log_type values for CLI commands. Keep this as ``str`` because Typer +# does not support Literal annotations for option parameters. +CliLogType = str + + +class LogFinalizer(Protocol): + def __call__(self) -> None: ... + + +def logger_for_log_type(log_type: CliLogType) -> tuple[Logger, LogFinalizer]: + """Return a logger/finalizer pair for TD-compatible CLI log types. + + ``console`` logs to stderr, ``none`` discards logs, and ``file`` captures + logs to a temporary file whose path is printed to stderr by the finalizer. + Callers that need custom structured stdout handling can use this lower-level + helper instead of :func:`run_with_logging`. + """ + + if log_type == "console": + return _default_logger, lambda: None + if log_type == "none": + return _null_logger, lambda: None + if log_type == "file": + capture = CaptureLogger() + + def finalize() -> None: + if logs := capture.get_logs(): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".log", prefix="tangle_", delete=False, + ) as f: + f.write(logs) + print(f"\nLogs written to: {f.name}", file=sys.stderr) + + return capture, finalize + raise SystemExit("--log-type must be one of: console, none, file") + + +def _print_result(result: Any) -> None: + """Print a function result as JSON (dicts) or plain text. + + Uses plain :func:`print` so this module has no CLI-framework + dependency. Concrete CLI wrappers built on top of ``tangle-cli`` + can wrap this with ``typer.echo`` / ``click.echo`` if they need + terminal-aware encoding handling. + """ + if result is None: + return + if isinstance(result, dict): + print(json.dumps(result, indent=2, default=str)) + else: + print(result) + + +def run_with_logging( + log_type: CliLogType, + fn: Callable[[Logger], dict[str, Any] | Any], +) -> None: + """Run *fn* with the appropriate logger for *log_type*, then handle output. + + This is the universal CLI wrapper for the ``--log-type`` flag: + + - **console** (default): logs stream to stdout/stderr via :class:`ConsoleLogger`. + If *fn* returns a non-None result, it is printed as JSON after the logs. + - **none**: logs are discarded. The function result is printed as JSON. + - **file**: logs are captured and written to a temp file whose path is + printed to stderr. The function result is printed as JSON. + + *fn* receives a :class:`Logger` and should return a dict (or any value). + Return ``None`` to suppress result output (useful when the logs *are* the output). + """ + logger, finalize = logger_for_log_type(log_type) + + try: + result = fn(logger) + _print_result(result) + finally: + finalize() diff --git a/packages/tangle-cli/src/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py new file mode 100644 index 0000000..8ae96da --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -0,0 +1,407 @@ +""" +API-contract dataclasses for the Tangle Cloud Pipelines API. + +These dataclasses model the shapes of HTTP request/response bodies on the +Tangle API — ``PipelineRun``, ``ComponentSpec``, container state, artifacts, +etc. They are used by wrapper packages and OpenAPI-backed client helpers. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse + +from .artifacts import ArtifactComponentQuery, ArtifactInfo + + +# ---- Execution / Run dataclasses ------------------------------------------- + + +@dataclass +class GraphExecutionState: + """Response from GET /api/executions/{id}/state. + + Maps each child execution ID to a dict of status -> count. + Example:: + + GraphExecutionState(child_execution_status_stats={ + "019c8b46508e751207fc": {"SUCCEEDED": 1}, + "019c8b46508e76e607fd": {"RUNNING": 2, "SUCCEEDED": 3}, + }) + """ + child_execution_status_stats: dict[str, dict[str, int]] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> GraphExecutionState: + return cls( + child_execution_status_stats=data.get("child_execution_status_stats", {}), + ) + + @property + def status_totals(self) -> dict[str, int]: + """Aggregate counts across all child executions.""" + totals: dict[str, int] = {} + for status_counts in self.child_execution_status_stats.values(): + for status, count in status_counts.items(): + totals[status] = totals.get(status, 0) + count + return totals + + @property + def failed_execution_ids(self) -> list[str]: + """Execution IDs that have at least one FAILED or SYSTEM_ERROR task.""" + return [ + exec_id + for exec_id, status_counts in self.child_execution_status_stats.items() + if status_counts.get("FAILED", 0) > 0 + or status_counts.get("SYSTEM_ERROR", 0) > 0 + ] + + +@dataclass +class PipelineRun: + """Response from GET /api/pipeline_runs/{id}.""" + id: str + root_execution_id: str | None + created_at: str | None = None + created_by: str | None = None + annotations: dict[str, str] | None = None + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PipelineRun: + return cls( + id=data["id"], + root_execution_id=data.get("root_execution_id"), + created_at=data.get("created_at"), + created_by=data.get("created_by"), + annotations=data.get("annotations"), + raw=data, + ) + + +@dataclass +class TaskSpec: + """A task within a pipeline execution graph. + + Recursive: a graph task contains child TaskSpecs via ``graph_tasks``. + Leaf tasks have a container implementation instead. + """ + name: str | None = None + component_spec: ComponentSpec | None = None + arguments: dict[str, str] = field(default_factory=dict) + graph_tasks: dict[str, TaskSpec] = field(default_factory=dict) + annotations: dict[str, str] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TaskSpec: + """Parse a task_spec dict (the shape returned by the API).""" + spec = data.get("componentRef", {}).get("spec", {}) + graph = spec.get("implementation", {}).get("graph", {}) + raw_tasks = graph.get("tasks", {}) + + graph_tasks: dict[str, TaskSpec] = {} + for task_name, task_data in raw_tasks.items(): + graph_tasks[task_name] = TaskSpec.from_dict(task_data) + + return cls( + name=spec.get("name"), + component_spec=ComponentSpec.from_spec(spec) if spec else None, + arguments=data.get("arguments", {}), + graph_tasks=graph_tasks, + annotations=data.get("annotations", {}), + raw=data, + ) + + @property + def digest(self) -> str | None: + """Component digest from componentRef.""" + return self.raw.get("componentRef", {}).get("digest") + + @property + def inputs(self) -> list[dict[str, Any]]: + """Component inputs.""" + return self.component_spec.inputs if self.component_spec else [] + + @property + def outputs(self) -> list[dict[str, Any]]: + """Component outputs.""" + return self.component_spec.outputs if self.component_spec else [] + + @property + def execution_id(self) -> str | None: + """Execution ID injected by ``_enrich_execution_tree``.""" + return self.raw.get("execution_id") + + @property + def execution_input_artifacts(self) -> dict[str, str]: + """Input artifact IDs injected by ``_enrich_execution_tree``.""" + return self.raw.get("input_artifacts", {}) + + @property + def execution_output_artifacts(self) -> dict[str, str]: + """Output artifact IDs injected by ``_enrich_execution_tree``.""" + return self.raw.get("output_artifacts", {}) + + @property + def is_graph(self) -> bool: + """True if this task is a subgraph (has child tasks).""" + return len(self.graph_tasks) > 0 + + def strip_implementations(self) -> None: + """Remove container implementation blocks recursively. + + Graph structure (tasks, arguments, connections) is preserved. + Only leaf container/code blocks are stripped. ``text`` fields + (raw YAML containing full implementations) are stripped at every + level to avoid leaking implementation details. + """ + if self.is_graph: + # Graph component: keep implementation dict but strip text fields + if self.component_spec: + self.component_spec.strip_implementation(keep_graph=True) + for child in self.graph_tasks.values(): + child.strip_implementations() + else: + if self.component_spec: + self.component_spec.strip_implementation() + + +# ---- Container state ------------------------------------------------------- + + +@dataclass +class KubernetesDebugInfo: + """Kubernetes debug info from container state.""" + pod_name: str | None = None + namespace: str | None = None + log_uri: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> KubernetesDebugInfo: + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) + + +@dataclass +class KubernetesJobInfo: + """Kubernetes job info from container state (debug_info.kubernetes_job).""" + job_name: str | None = None + namespace: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> KubernetesJobInfo: + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) + + +@dataclass +class DebugInfo: + """Debug info from container state (mirrors debug_info in the API response).""" + kubernetes: KubernetesDebugInfo | None = None + kubernetes_job: KubernetesJobInfo | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DebugInfo: + k8s_data = data.get("kubernetes", {}) + job_data = data.get("kubernetes_job", {}) + return cls( + kubernetes=KubernetesDebugInfo.from_dict(k8s_data) if k8s_data else None, + kubernetes_job=KubernetesJobInfo.from_dict(job_data) if job_data else None, + ) + + +@dataclass +class ContainerState: + """Response from GET /api/executions/{id}/container_state. + + Extracts key fields for debugging; the full Kubernetes debug info + (pod spec, status, etc.) is available via ``debug_info.kubernetes`` and ``raw``. + """ + status: str = "UNKNOWN" + exit_code: int | None = None + started_at: str | None = None + ended_at: str | None = None + pod_name: str | None = None + namespace: str | None = None + debug_info: DebugInfo | None = None + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ContainerState: + debug_data = data.get("debug_info", {}) + fields = {k: v for k, v in data.items() if k in cls.__dataclass_fields__} + if debug_data: + fields["debug_info"] = DebugInfo.from_dict(debug_data) + fields["raw"] = data + + # Resolve pod_name: debug_info.kubernetes.pod_name > debug_info.kubernetes_job.job_name + if not fields.get("pod_name"): + di = fields.get("debug_info") + k8s = di.kubernetes if di else None + if k8s and k8s.pod_name: + fields["pod_name"] = k8s.pod_name + if not fields.get("namespace"): + fields["namespace"] = k8s.namespace + else: + job = di.kubernetes_job if di else None + if job and job.job_name: + fields["pod_name"] = job.job_name + + return cls(**fields) + + +# ---- Composite ------------------------------------------------------------- + + +@dataclass +class RunDetails: + """Combined pipeline run + execution details from get_run_details.""" + run: PipelineRun + execution: GetExecutionInfoResponse | None = None + annotations: dict[str, str | None] | None = None + execution_state: GraphExecutionState | None = None + + +# ---- Users / secrets ------------------------------------------------------- + + +@dataclass +class UserInfo: + """Current authenticated user from /api/users/me.""" + id: str + permissions: list[str] + + +@dataclass +class SecretInfo: + """Secret metadata from /api/secrets/ endpoints.""" + secret_name: str + created_at: str + updated_at: str + expires_at: str | None = None + description: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SecretInfo: + return cls( + secret_name=data["secret_name"], + created_at=data["created_at"], + updated_at=data["updated_at"], + expires_at=data.get("expires_at"), + description=data.get("description"), + ) + + +# ---- 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``. + + +@dataclass +class ComponentInfo: + """Merged view of a published component: spec + publication metadata.""" + + name: str = "" + digest: str | None = None + version: str | None = None + published_by: str | None = None + deprecated: bool = False + superseded_by: str | None = None + description: str = "" + component_spec: ComponentSpec | None = None + spec_error: str | None = None + + @classmethod + def from_dict(cls, pub: dict[str, Any]) -> ComponentInfo: + """Create from a published_components API response entry.""" + return cls( + name=pub.get("name", ""), + digest=pub.get("digest"), + version=pub.get("version"), + published_by=pub.get("published_by"), + deprecated=pub.get("deprecated", False), + superseded_by=pub.get("superseded_by"), + description=pub.get("description", ""), + ) + + def to_dict(self, strip_spec: bool = True) -> dict[str, Any]: + """Serialize to a dict, omitting None/empty optional fields. + + Args: + strip_spec: If True (default), strip bulky annotations and + implementation blocks from the component spec. + """ + d: dict[str, Any] = {"digest": self.digest, "version": self.version} + if self.published_by is not None: + d["published_by"] = self.published_by + d["deprecated"] = self.deprecated + if self.superseded_by is not None: + d["superseded_by"] = self.superseded_by + if self.description: + d["description"] = self.description + if self.component_spec is not None: + spec = self.component_spec.stripped_spec if strip_spec else self.component_spec.data + if spec is not None: + d["spec"] = spec + if self.spec_error is not None: + d["spec_error"] = self.spec_error + return d + + +# ---- Pagination ------------------------------------------------------------ + + +@dataclass +class PageChunk: + """Metadata for a single page of search results.""" + + rows: list[dict[str, Any]] + page_token: str | None + next_page_token: str | None + ui_filter_url: str + next_ui_filter_url: str | None + + +# ---- Dict-like compatibility ---------------------------------------------- + + +def _dataclass_to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _dataclass_get(self, key: str, default: Any = None) -> Any: + return _dataclass_to_dict(self).get(key, default) + + +def _dataclass_getitem(self, key: str) -> Any: + return _dataclass_to_dict(self)[key] + + +for _dict_like_cls in ( + GraphExecutionState, + PipelineRun, + TaskSpec, + KubernetesDebugInfo, + KubernetesJobInfo, + DebugInfo, + ContainerState, + RunDetails, + ArtifactComponentQuery, + ArtifactInfo, + UserInfo, + SecretInfo, + ComponentInfo, + PageChunk, +): + if not hasattr(_dict_like_cls, "to_dict"): + setattr(_dict_like_cls, "to_dict", _dataclass_to_dict) + setattr(_dict_like_cls, "get", _dataclass_get) + setattr(_dict_like_cls, "__getitem__", _dataclass_getitem) + + +del _dict_like_cls diff --git a/packages/tangle-cli/src/tangle_cli/module_bundler.py b/packages/tangle-cli/src/tangle_cli/module_bundler.py new file mode 100644 index 0000000..bf796ce --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/module_bundler.py @@ -0,0 +1,662 @@ +"""Discovers local Python modules, bundles their source, and generates injection code. + +Provides ``ModuleBundler`` for embedding local dependency modules into generated +components so they are available at runtime without requiring the original +package to be installed in the container. + +Also contains ``classify_imports`` — the import classification utility used by +both the component generator and the airflow converter. +""" + +import ast +import base64 +import importlib.util +import json +import os +import re +import sys +import textwrap +from collections.abc import Iterator +from pathlib import Path +from typing import Literal + +# Paths that indicate a module is installed (not local project code). +_INSTALLED_PACKAGE_MARKERS = ("site-packages", "dist-packages") + +# ============================================================================= +# Import classification +# ============================================================================= + + +def classify_imports( + file_path: Path, + pip_deps: list[str] | None = None, + resolve_root: Path | None = None, + source: str | None = None, +) -> dict[str, Literal["stdlib", "third_party", "local"]]: + """Classify imports in a Python file as stdlib, third-party, or local. + + Args: + file_path: Path to the Python source file + pip_deps: List of pip dependency strings (e.g., ["pandas==2.0", "requests>=2.28"]) + resolve_root: Directory to check for local modules. Defaults to file_path.parent. + Use this when imports resolve relative to a different root (e.g., dags_root + for Airflow DAG files). + source: Pre-read source text. If provided, the file is not read again. + + Returns: + Dict mapping module names to their classification. + """ + if source is None: + source = file_path.read_text() + tree = ast.parse(source) + + # Extract top-level module names from pip deps + third_party_names: set[str] = set() + if pip_deps: + for dep in pip_deps: + # Extract package name from dependency spec like "pandas==2.0.0" or "requests>=2.28" + name = re.split(r'[><=!~\[]', dep)[0].strip().lower() + # Normalize: pip package names use hyphens, import names use underscores + third_party_names.add(name.replace("-", "_")) + + # Get stdlib module names + if hasattr(sys, "stdlib_module_names"): + stdlib_names: frozenset[str] | set[str] = sys.stdlib_module_names + else: + stdlib_names = set(sys.builtin_module_names) + + result: dict[str, Literal["stdlib", "third_party", "local"]] = {} + file_dir = resolve_root or file_path.parent + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + mod_name = alias.name.split(".")[0] + result[mod_name] = _classify_module(mod_name, stdlib_names, third_party_names, file_dir) + elif isinstance(node, ast.ImportFrom): + if node.module and node.level == 0: + mod_name = node.module.split(".")[0] + result[mod_name] = _classify_module(mod_name, stdlib_names, third_party_names, file_dir) + elif node.level > 0: + # Relative imports are always local + if node.module: + result[node.module.split(".")[0]] = "local" + elif node.names: + # `from . import helpers` — module is None, names has the imports + for alias in node.names: + result[alias.name.split(".")[0]] = "local" + + return result + + +def _classify_module( + mod_name: str, + stdlib_names: frozenset[str] | set[str], + third_party_names: set[str], + file_dir: Path, +) -> Literal["stdlib", "third_party", "local"]: + """Classify a single module name. + + Uses a two-pass approach: + + 1. **Filesystem check** — looks for ``.py`` or + ``/__init__.py`` directly under *file_dir*. + 2. **importlib fallback** — uses ``importlib.util.find_spec`` to locate the + module on ``sys.path``. If the resolved origin is *not* inside + ``site-packages`` or ``dist-packages`` it is treated as a local module. + This handles project layouts where local modules live in sibling + directories (e.g. ``src/utils`` next to ``src/components``). + + Args: + mod_name: Top-level module name (e.g., "local_modules") + stdlib_names: Set of standard library module names + third_party_names: Set of third-party package names + file_dir: Directory to check for local files/packages + """ + if mod_name in stdlib_names: + return "stdlib" + if mod_name.lower() in third_party_names: + return "third_party" + # Check if a local .py file or package exists under file_dir + if (file_dir / f"{mod_name}.py").exists(): + return "local" + if (file_dir / mod_name / "__init__.py").exists(): + return "local" + # Fallback: use importlib to search sys.path for modules in sibling directories + if _is_local_via_importlib(mod_name): + return "local" + # Assume third-party if we can't determine + return "third_party" + + +def _is_local_via_importlib(mod_name: str) -> bool: + """Check whether *mod_name* resolves to a local (non-installed) module. + + Returns ``True`` when ``importlib.util.find_spec`` finds the module and its + origin path does **not** contain ``site-packages`` or ``dist-packages``. + + Note: ``find_spec`` may execute parent package ``__init__.py`` files as a + side effect when resolving dotted names. We catch all exceptions broadly + so that package-init failures (``RuntimeError``, ``KeyError``, etc.) do not + break the static generation step. + """ + try: + spec = importlib.util.find_spec(mod_name) + if spec is None: + return False + # Namespace packages have no origin — check submodule_search_locations + origin = spec.origin + search_locations = spec.submodule_search_locations + path_to_check = origin or (str(search_locations[0]) if search_locations else None) + if not path_to_check: + return False + return not any(marker in path_to_check for marker in _INSTALLED_PACKAGE_MARKERS) + except Exception: + return False + + +# ============================================================================= +# ModuleBundler +# ============================================================================= + + +class ModuleBundler: + """Discovers local Python modules, bundles their source, and generates injection code. + + Usage:: + + module_sources = ModuleBundler.collect_sources(dag_file, resolve_root=dags_root) + b64 = ModuleBundler.encode(module_sources) + snippet = ModuleBundler.build_injection(b64) + """ + + @staticmethod + def classify_imports( + file_path: Path, + pip_deps: list[str] | None = None, + resolve_root: Path | None = None, + ) -> dict[str, Literal["stdlib", "third_party", "local"]]: + """Classify imports in a Python file as stdlib, third-party, or local. + + Args: + file_path: Path to the Python source file + pip_deps: List of pip dependency strings (e.g., ["pandas==2.0", "requests>=2.28"]) + resolve_root: Directory to check for local modules. Defaults to file_path.parent. + + Returns: + Dict mapping module names to their classification. + """ + return classify_imports(file_path, pip_deps, resolve_root) + + @staticmethod + def collect_sources( + file_path: Path, + resolve_root: Path | None = None, + pip_deps: list[str] | None = None, + source: str | None = None, + ) -> dict[str, str]: + """Collect source text of local dependency modules from disk. + + Resolves local imports via AST analysis and filesystem lookup, without + requiring modules to be loaded in ``sys.modules``. + + For each local import found by ``classify_imports``, the function resolves the + full dotted module path to a ``.py`` file (or ``__init__.py`` package) under + *resolve_root* and reads its source text. Transitive local imports within + each discovered module are also collected recursively. + + Args: + file_path: Python source file whose imports to analyse. + resolve_root: Root directory for local module resolution. Defaults to + ``file_path.parent``. + pip_deps: Pip dependency strings passed through to ``classify_imports``. + source: Source text to analyse instead of reading *file_path*. When + provided, only imports present in this text are considered. This + is useful for scoping the bundle to a specific callable rather + than the entire file. + + Returns: + ``{dotted_module_name: source_text}`` for every discovered local module. + """ + root = resolve_root or file_path.parent + if source is None: + source = file_path.read_text() + classifications = classify_imports(file_path, pip_deps, resolve_root=root, source=source) + + local_top_names = {name for name, cls in classifications.items() if cls == "local"} + if not local_top_names: + return {} + + # Walk the AST to collect full dotted module paths (classify_imports only + # records top-level names, e.g. "local_modules" from "from local_modules.dw import X"). + full_module_paths = _collect_full_module_paths(source, local_top_names) + + # Resolve each module path to a source file and read it + result: dict[str, str] = {} + visited: set[str] = set() + _resolve_modules_recursive(full_module_paths, root, result, visited, pip_deps) + return result + + @staticmethod + def encode(module_sources: dict[str, str]) -> str | None: + """Compress and base64-encode a dict of module sources for embedding. + + Modules are sorted so that dependencies execute before dependents. + We perform a topological sort over the module-level import graph + between bundled modules, with parent packages preceding their + submodules. This ensures references made *at module load time* + (e.g. ``FOO = bbb.bar()`` at the top of ``aaa.py``) find their + target already executed — sorting purely by depth + name fails + whenever a dependent sorts before its dependency (issue #30197). + + If the dependency graph contains a cycle (which would also fail + under a normal Python import for any module-level reference), we + fall back to ``(depth, alphabetical)`` order so output stays + deterministic. + + Args: + module_sources: ``{module_name: source_text}`` dict. + + Returns: + Base64-encoded string, or ``None`` if *module_sources* is empty. + """ + if not module_sources: + return None + import zlib + ordered_names = _topological_order(module_sources) + ordered = {name: module_sources[name] for name in ordered_names} + sources_json = json.dumps(ordered) + compressed = zlib.compress(sources_json.encode(), level=9) + return base64.b64encode(compressed).decode("ascii") + + @staticmethod + def build_injection(bundled_modules_b64: str) -> str: + """Return a Python snippet that decodes and injects bundled modules into ``sys.modules``. + + The snippet is self-contained: it imports ``sys``, ``types``, ``base64``, + ``json``, and ``zlib``, then decompresses the embedded blob and registers + each module via ``types.ModuleType`` + ``exec``. + + Args: + bundled_modules_b64: Base64 string produced by ``encode``. + """ + return textwrap.dedent(f"""\ + # --- Inject local dependency modules from embedded source --- + import sys + import types + import base64 + import json + import zlib + + _EMBEDDED_MODULES = json.loads(zlib.decompress(base64.b64decode({repr(bundled_modules_b64)}))) + # Pass 1: register all modules in sys.modules (without executing source) + # so transitive imports between bundled modules can resolve in any order. + _module_objs = {{}} + _package_names = set() + for _mod_name in _EMBEDDED_MODULES: + _parts = _mod_name.split('.') + for _i in range(1, len(_parts)): + _package_names.add('.'.join(_parts[:_i])) + for _mod_name in _EMBEDDED_MODULES: + _parts = _mod_name.split('.') + for _i in range(1, len(_parts)): + _parent = '.'.join(_parts[:_i]) + if _parent not in sys.modules: + _pkg = types.ModuleType(_parent) + _pkg.__path__ = [] + _pkg.__package__ = _parent + sys.modules[_parent] = _pkg + _mod = sys.modules.get(_mod_name) + if _mod is None or _mod_name not in _package_names: + _mod = types.ModuleType(_mod_name) + sys.modules[_mod_name] = _mod + _is_package = _mod_name in _package_names + _mod.__package__ = _mod_name if _is_package else ('.'.join(_parts[:-1]) if len(_parts) > 1 else '') + if _is_package: + _mod.__path__ = [] + if len(_parts) > 1: + setattr(sys.modules['.'.join(_parts[:-1])], _parts[-1], _mod) + _module_objs[_mod_name] = _mod + # Pass 2: execute source in all registered modules + for _mod_name, _mod_source in _EMBEDDED_MODULES.items(): + _code = compile(_mod_source, _mod_name.replace('.', '/') + '.py', 'exec') + exec(_code, _module_objs[_mod_name].__dict__)""") + + +# ============================================================================= +# Private helpers +# ============================================================================= + + +def _topological_order(module_sources: dict[str, str]) -> list[str]: + """Return bundled module names sorted so dependencies precede dependents. + + Builds a graph of module-level imports between bundled modules and + runs ``graphlib.TopologicalSorter``. Falls back to ``(depth, + alphabetical)`` ordering when the graph contains a cycle so output + remains deterministic. + """ + from graphlib import CycleError, TopologicalSorter + + bundled = set(module_sources) + # Insert nodes in a deterministic order so the topological sort's + # tie-breaking (insertion order, when multiple nodes are ready) is + # stable across runs. + fallback_order = sorted(bundled, key=lambda n: (n.count("."), n)) + graph: dict[str, set[str]] = {name: set() for name in fallback_order} + for name in fallback_order: + graph[name] = _module_level_dependencies(name, module_sources[name], bundled) + + try: + return list(TopologicalSorter(graph).static_order()) + except CycleError: + return fallback_order + + +def _module_level_dependencies(name: str, source: str, bundled: set[str]) -> set[str]: + """Return bundled modules that *name* depends on at module load time. + + Considers only imports that execute when the module is first run + (i.e. excludes imports nested inside function or lambda bodies). + + Note we deliberately do *not* add a blanket "parent package before + child module" edge. Pass 1 of the runtime injection registers every + bundled module in ``sys.modules`` up front, so a child can resolve + ``import `` regardless of execution order. A child only + needs its parent exec'd first if it references the parent's + attributes at module load — and that case shows up as an explicit + ``from import ...`` / ``import `` in the child's + source, which is captured below. Adding a blanket parent-before- + child edge would also create a spurious cycle whenever the parent's + ``__init__.py`` does ``from . import sibling`` (a common pattern), + forcing the topological sort to fall back to the legacy alphabetical + order — the very behavior this function exists to replace. + """ + deps: set[str] = set() + + try: + tree = ast.parse(source) + except SyntaxError: + return deps + + # Mirrors the package-context convention used elsewhere in the + # bundler (see ``_resolve_modules_recursive``): top-level modules + # use themselves as the package context, submodules use their + # immediate parent. + parts = name.split(".") + pkg_context = ".".join(parts[:-1]) if len(parts) > 1 else name + + for node in _iter_module_level_nodes(tree): + if not isinstance(node, (ast.Import, ast.ImportFrom)): + continue + for target in _import_node_targets(node, pkg_context): + # Match the longest dotted prefix that is bundled — handles + # ``from pkg.sub import mod`` where ``pkg.sub.mod`` is the + # bundled submodule. + tparts = target.split(".") + for j in range(len(tparts), 0, -1): + candidate = ".".join(tparts[:j]) + if candidate in bundled and candidate != name: + deps.add(candidate) + break + return deps + + +def _iter_module_level_nodes(tree: ast.AST) -> Iterator[ast.AST]: + """Yield AST nodes that execute at module load time. + + Skips function and lambda bodies — imports inside those only run + when the function is called, so they do not constrain the order in + which bundled modules must be executed. Class bodies and + ``if``/``try``/``with`` statements at module scope *are* executed + at module load time and are walked normally. + """ + if isinstance(tree, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + return + yield tree + for child in ast.iter_child_nodes(tree): + yield from _iter_module_level_nodes(child) + + +def _import_node_targets( + node: ast.Import | ast.ImportFrom, pkg_context: str, +) -> list[str]: + """Return the dotted module paths an import node refers to. + + For ``from pkg import a, b`` we return ``pkg``, ``pkg.a``, and + ``pkg.b`` — names that turn out to be attributes (not submodules) + are filtered out by the caller via the ``bundled`` membership check. + Relative imports are resolved against *pkg_context* using the same + convention as ``_collect_full_module_paths``. + """ + targets: list[str] = [] + if isinstance(node, ast.Import): + for alias in node.names: + targets.append(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module and node.level == 0: + targets.append(node.module) + for alias in node.names: + targets.append(f"{node.module}.{alias.name}") + elif node.level > 0: + if pkg_context: + ctx_parts = pkg_context.split(".") + base = ".".join(ctx_parts[: max(0, len(ctx_parts) - (node.level - 1))]) + if node.module: + resolved = f"{base}.{node.module}" if base else node.module + targets.append(resolved) + for alias in node.names: + targets.append(f"{resolved}.{alias.name}") + else: + for alias in node.names: + targets.append(f"{base}.{alias.name}" if base else alias.name) + elif node.module: + targets.append(node.module) + return targets + + +def _collect_full_module_paths( + source: str, local_top_names: set[str], package_context: str = "", +) -> set[str]: + """Extract full dotted module paths for imports whose top-level name is local. + + Args: + source: Python source code to scan. + local_top_names: Set of top-level module names classified as local. + package_context: Dotted package name of the module being scanned. + Used to resolve relative imports (e.g., ``from .defaults import X`` + inside ``local_helpers.config`` becomes ``local_helpers.defaults``). + """ + tree = ast.parse(source) + paths: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + top = alias.name.split(".")[0] + if top in local_top_names: + paths.add(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module and node.level == 0: + top = node.module.split(".")[0] + if top in local_top_names: + paths.add(node.module) + # Also add child paths for each imported name — if the + # imported name is a submodule (e.g. `from pkg.sub import + # mod` where `pkg/sub/mod.py` exists), it needs to be + # bundled too. Non-module names (functions, classes) will + # simply fail to resolve later and be ignored. + for alias in node.names: + paths.add(f"{node.module}.{alias.name}") + elif node.level > 0: + # Relative import — resolve to absolute path using package context. + # Relative imports are always local by definition, so no need to + # check against local_top_names. + if package_context: + # Go up `level` packages from the current package + parts = package_context.split(".") + base = ".".join(parts[: max(0, len(parts) - (node.level - 1))]) + if node.module: + resolved = f"{base}.{node.module}" if base else node.module + paths.add(resolved) + # Also add child paths for imported names (submodule case) + for alias in node.names: + paths.add(f"{resolved}.{alias.name}") + else: + # `from . import X` — import names are the modules + for alias in node.names: + paths.add(f"{base}.{alias.name}" if base else alias.name) + elif node.module: + # No package context — fall back to recording verbatim + top = node.module.split(".")[0] + if top in local_top_names: + paths.add(node.module) + for alias in node.names: + paths.add(f"{node.module}.{alias.name}") + return paths + + +def _resolve_module_file(dotted_name: str, root: Path) -> Path | None: + """Resolve a dotted module name to a source file under *root*. + + Checks (in order): + 1. ``root/a/b/c.py`` (module) + 2. ``root/a/b/c/__init__.py`` (package) + 3. ``importlib.util.find_spec`` fallback — resolves modules on ``sys.path`` + that live outside *root* (e.g. sibling directories). Only non-installed + (non-``site-packages``) modules are accepted, and when *root* is an + explicit ``resolve_root`` the resolved path must share a common project + ancestor with *root* to prevent bundling code from unrelated projects. + """ + parts = dotted_name.replace(".", "/") + candidate = root / (parts + ".py") + if candidate.exists(): + return candidate + candidate = root / parts / "__init__.py" + if candidate.exists(): + return candidate + # Fallback: use importlib to find modules in sibling directories + return _resolve_module_file_via_importlib(dotted_name, root) + + +def _resolve_module_file_via_importlib(dotted_name: str, root: Path) -> Path | None: + """Resolve a dotted module name to a Python **source** file via ``importlib``. + + Returns the file path only when the module is *not* installed in + ``site-packages`` / ``dist-packages`` (i.e. it is a local project module), + the origin is a ``.py`` file, and the resolved path shares a common + ancestor with *root* (i.e. lives in the same project tree). Extension + modules (``.so``, ``.pyd``) are excluded because the bundler reads source + text via ``read_text()``. + + Note: ``find_spec`` may execute parent package ``__init__.py`` files as a + side effect when resolving dotted names. We catch all exceptions broadly + so that package-init failures do not break the static generation step. + """ + try: + spec = importlib.util.find_spec(dotted_name) + if spec is None: + return None + origin = spec.origin + if not origin or origin == "frozen": + return None + # Only accept Python source files — extension modules (.so, .pyd) + # cannot be read as text and must not be bundled. + if not origin.endswith(".py"): + return None + origin_path = Path(origin).resolve() + if not origin_path.exists(): + return None + origin_str = str(origin_path) + if any(marker in origin_str for marker in _INSTALLED_PACKAGE_MARKERS): + return None + # Guard: the resolved file must live under the same project tree as + # root. We check that root and origin share a meaningful common + # ancestor (more specific than just "/" or a drive letter) to prevent + # silently bundling code from unrelated projects on sys.path. + resolved_root = root.resolve() + try: + # If origin is under root, great — always accept. + origin_path.relative_to(resolved_root) + except ValueError: + # Origin is outside root. Accept only if they share a common + # ancestor that is at least 2 levels deep (e.g. /Users/me/project, + # not just / or /Users). + common = Path(os.path.commonpath([resolved_root, origin_path])) + if len(common.parts) <= 2: + return None + return origin_path + except Exception: + return None + + +def _resolve_modules_recursive( + module_paths: set[str], + root: Path, + result: dict[str, str], + visited: set[str], + pip_deps: list[str] | None, +) -> None: + """Resolve module paths to source text, following transitive local imports.""" + for dotted in sorted(module_paths): + if dotted in visited: + continue + visited.add(dotted) + + mod_file = _resolve_module_file(dotted, root) + if not mod_file: + # Also try the top-level name (package __init__) + top = dotted.split(".")[0] + if top not in visited: + visited.add(top) + pkg_init = _resolve_module_file(top, root) + if pkg_init: + result[top] = pkg_init.read_text() + continue + + mod_source = mod_file.read_text() + result[dotted] = mod_source + + # Ensure all parent packages are collected (e.g. for "a.b.c", + # collect "a" and "a.b" __init__.py files). Python always + # populates parent packages during import resolution, so the + # bundle must include them for runtime correctness. + # We also follow transitive imports in each parent __init__.py, + # since Python executes them at import time and they may pull in + # sibling modules (e.g. ``from . import helpers``). + parts = dotted.split(".") + for i in range(1, len(parts)): + parent = ".".join(parts[:i]) + if parent not in visited: + visited.add(parent) + parent_file = _resolve_module_file(parent, root) + if parent_file: + parent_source = parent_file.read_text() + result[parent] = parent_source + # Follow transitive local imports within the parent __init__.py + try: + parent_classifications = classify_imports( + parent_file, pip_deps, resolve_root=root, source=parent_source, + ) + parent_local = {name for name, cls in parent_classifications.items() if cls == "local"} + if parent_local: + parent_paths = _collect_full_module_paths( + parent_source, parent_local, package_context=parent, + ) + _resolve_modules_recursive(parent_paths, root, result, visited, pip_deps) + except Exception: + pass # Best-effort transitive resolution + + # Follow transitive local imports within this module. + # Derive the package context so relative imports resolve correctly: + # e.g., module "local_helpers.config" has package context "local_helpers" + parts = dotted.split(".") + pkg_context = ".".join(parts[:-1]) if len(parts) > 1 else dotted + try: + child_classifications = classify_imports(mod_file, pip_deps, resolve_root=root, source=mod_source) + child_local = {name for name, cls in child_classifications.items() if cls == "local"} + if child_local: + child_paths = _collect_full_module_paths(mod_source, child_local, package_context=pkg_context) + _resolve_modules_recursive(child_paths, root, result, visited, pip_deps) + except Exception: + pass # Best-effort transitive resolution diff --git a/packages/tangle-cli/src/tangle_cli/openapi/__init__.py b/packages/tangle-cli/src/tangle_cli/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py new file mode 100644 index 0000000..08c0026 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -0,0 +1,1090 @@ +"""Generate the checked-in static Tangle API client pieces from OpenAPI. + +Run from the repository root with: + + uv run python -m tangle_cli.openapi.codegen + +The generator intentionally reuses :mod:`tangle_cli.api_schema` for operation +normalization so the offline client keeps the dynamic CLI/client expansion +semantics without requiring OpenAPI parsing at normal runtime. +""" + +from __future__ import annotations + +import argparse +import copy +import importlib +import json +import keyword +import os +import re +import sys +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 + +from .parser import ( + DEFAULT_OPENAPI_PATH, + DEFAULT_OPENAPI_RESOURCE_NAME, + DEFAULT_OPENAPI_RESOURCE_PACKAGE, + load_openapi_schema, + parsed_operations, +) + +_REPO_ROOT = Path(__file__).resolve().parents[5] +_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", + "ComponentSpecOutput", + "ComponentSpec-Input", + "ComponentSpecInput", + ), +} + + +def _safe_identifier(name: str) -> str: + value = re.sub(r"\W", "_", name).strip("_").lower() + value = re.sub(r"_+", "_", value) or "value" + if value[0].isdigit(): + value = f"value_{value}" + if keyword.iskeyword(value): + value = f"{value}_" + return value + + +def _class_name(name: str) -> str: + parts = re.split(r"[^0-9A-Za-z]+", name) + value = "".join(part[:1].upper() + part[1:] for part in parts if part) + if not value: + value = "GeneratedModel" + if value[0].isdigit(): + value = f"Model{value}" + return value + + +def _schema_ref_name( + schema: dict[str, Any] | None, + model_ref_aliases: dict[str, str] | None = None, +) -> str | None: + if not schema: + return None + ref = schema.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + schema_name = ref.rsplit("/", 1)[1] + return model_ref_aliases.get(schema_name, _class_name(schema_name)) if model_ref_aliases else _class_name(schema_name) + for key in ("anyOf", "oneOf", "allOf"): + for child in schema.get(key, []) or []: + name = _schema_ref_name(child, model_ref_aliases=model_ref_aliases) + if name: + return name + return None + + +def _success_response(operation: dict[str, Any]) -> dict[str, Any] | None: + responses = operation.get("responses", {}) or {} + for status in ("200", "201", "202", "204", "default"): + response = responses.get(status) + if response: + break + else: + response = next(iter(responses.values()), None) + return response if isinstance(response, dict) else None + + +def _success_schema(operation: dict[str, Any]) -> dict[str, Any] | None: + response = _success_response(operation) + if response is None: + return None + content = response.get("content", {}) or {} + json_content = content.get("application/json") or next(iter(content.values()), {}) + schema = json_content.get("schema") if isinstance(json_content, dict) else None + return schema if isinstance(schema, dict) else None + + +def _schema_type(schema: dict[str, Any]) -> str | None: + schema_type = schema.get("type") + if isinstance(schema_type, str): + return schema_type + if isinstance(schema_type, list): + for item in schema_type: + if item != "null": + return str(item) + return None + + +def _schema_allows_null(schema: dict[str, Any] | None) -> bool: + if not schema: + return False + if schema.get("nullable") is True or schema.get("type") == "null": + return True + schema_type = schema.get("type") + if isinstance(schema_type, list) and "null" in schema_type: + return True + for key in ("anyOf", "oneOf"): + for child in schema.get(key, []) or []: + if isinstance(child, dict) and _schema_allows_null(child): + return True + return False + + +def _response_model_name( + operation: dict[str, Any], + model_ref_aliases: dict[str, str] | None = None, +) -> str | None: + schema = _success_schema(operation) + if not schema: + return None + if _schema_type(schema) == "array": + items = schema.get("items") + return _schema_ref_name(items if isinstance(items, dict) else None, model_ref_aliases=model_ref_aliases) + return _schema_ref_name(schema, model_ref_aliases=model_ref_aliases) + + +def _response_return_annotation( + operation: dict[str, Any], + model_ref_aliases: dict[str, str] | None = None, +) -> str: + response = _success_response(operation) + if response is None: + return "Any" + schema = _success_schema(operation) + if schema is None or not schema: + return "None" + return _schema_return_annotation(schema, model_ref_aliases=model_ref_aliases) + + +def _schema_return_annotation( + schema: dict[str, Any], + model_ref_aliases: dict[str, str] | None = None, +) -> str: + ref_name = _schema_ref_name(schema, model_ref_aliases=model_ref_aliases) + if ref_name: + return f"{ref_name} | None" if _schema_allows_null(schema) else ref_name + + schema_type = _schema_type(schema) + if schema_type == "array": + items = schema.get("items") + item_ref = _schema_ref_name(items if isinstance(items, dict) else None, model_ref_aliases=model_ref_aliases) + annotation = f"list[{item_ref}]" if item_ref else "list[Any]" + return f"{annotation} | None" if _schema_allows_null(schema) else annotation + + primitives = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + } + if schema_type in primitives: + annotation = primitives[schema_type] + return f"{annotation} | None" if _schema_allows_null(schema) else annotation + + if schema_type == "object" or "properties" in schema or "additionalProperties" in schema: + return "dict[str, Any]" + + return "Any" + + + +def _parse_model_alias(value: str) -> tuple[str, tuple[str, ...]]: + """Parse ``PublicModel=SourceModel[,OtherSource]`` alias config.""" + + if "=" not in value: + raise ValueError( + "Model aliases must use PublicModel=SourceModel[,OtherSource] syntax" + ) + alias_name, raw_sources = value.split("=", 1) + alias_name = _class_name(alias_name.strip()) + _validate_class_name(alias_name) + sources = tuple(source.strip() for source in raw_sources.split(",") if source.strip()) + if not sources: + raise ValueError(f"Model alias {alias_name!r} must include at least one source schema") + return alias_name, sources + + +def _model_alias_mapping( + model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None, +) -> dict[str, tuple[str, ...]]: + """Return public model aliases, applying built-in defaults first. + + A string or sequence uses CLI-style ``PublicModel=SourceModel`` entries. + An empty-string entry disables the built-in defaults. + """ + + aliases = dict(DEFAULT_MODEL_ALIASES) + if model_aliases is None: + return aliases + if isinstance(model_aliases, dict): + for alias_name, sources in model_aliases.items(): + parsed_alias = _class_name(alias_name) + _validate_class_name(parsed_alias) + source_values = [sources] if isinstance(sources, str) else list(sources) + source_tuple = tuple(str(source).strip() for source in source_values if str(source).strip()) + if not source_tuple: + raise ValueError(f"Model alias {parsed_alias!r} must include at least one source schema") + aliases[parsed_alias] = source_tuple + return aliases + + values = [model_aliases] if isinstance(model_aliases, str) else list(model_aliases) + if "" in values: + aliases = {} + values = [value for value in values if value != ""] + for value in values: + alias_name, sources = _parse_model_alias(value) + aliases[alias_name] = sources + return aliases + + +def _apply_model_aliases( + schemas: dict[str, Any], + model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None, +) -> tuple[dict[str, Any], dict[str, str]]: + """Add alias schemas and return source schema -> public class aliases.""" + + output = dict(schemas) + existing_class_names = {_class_name(schema_name) for schema_name in output} + model_ref_aliases: dict[str, str] = {} + for alias_name, sources in _model_alias_mapping(model_aliases).items(): + present_sources = [source for source in sources if source in schemas] + if not present_sources: + continue + if alias_name not in existing_class_names: + output[alias_name] = dict(schemas[present_sources[0]]) + if isinstance(output[alias_name], dict): + output[alias_name]["title"] = alias_name + existing_class_names.add(alias_name) + if alias_name in existing_class_names: + for source in present_sources: + model_ref_aliases[source] = alias_name + return output, model_ref_aliases + + +def _request_body_schema_mapping( + request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None, +) -> dict[str, dict[str, Any]]: + """Parse operation request-body schema overrides keyed by operation id.""" + + if request_body_schemas is None: + return {} + if isinstance(request_body_schemas, dict): + return {key: dict(value) for key, value in request_body_schemas.items()} + + values = [request_body_schemas] if isinstance(request_body_schemas, str) else list(request_body_schemas) + overrides: dict[str, dict[str, Any]] = {} + for value in values: + if "=" not in value: + raise ValueError( + "Request body schema overrides must use OperationId={...json schema...} syntax" + ) + operation_id, raw_schema = value.split("=", 1) + operation_id = operation_id.strip() + if not operation_id: + raise ValueError("Request body schema override operation id cannot be empty") + try: + schema = json.loads(raw_schema) + except json.JSONDecodeError as exc: + raise ValueError( + f"Request body schema override for {operation_id!r} is not valid JSON: {exc.msg}" + ) from exc + if not isinstance(schema, dict): + raise ValueError(f"Request body schema override for {operation_id!r} must be a JSON object") + overrides[operation_id] = schema + return overrides + + +def _request_body_schema_file_mapping(values: Sequence[str] | str | None) -> dict[str, dict[str, Any]]: + """Parse operation request-body schema overrides from JSON files.""" + + if values is None: + return {} + raw_values = [values] if isinstance(values, str) else list(values) + overrides: dict[str, dict[str, Any]] = {} + for value in raw_values: + if "=" not in value: + raise ValueError( + "Request body schema file overrides must use OperationId=path/to/schema.json syntax" + ) + operation_id, raw_path = value.split("=", 1) + operation_id = operation_id.strip() + if not operation_id: + raise ValueError("Request body schema file override operation id cannot be empty") + path = Path(raw_path).expanduser() + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ValueError(f"Could not read request body schema file {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"Request body schema file {path} is not valid JSON: {exc.msg}") from exc + if not isinstance(schema, dict): + raise ValueError(f"Request body schema file {path} must contain a JSON object") + overrides[operation_id] = schema + return overrides + + +def _operation_override_keys(operation: Any) -> set[str]: + """Return supported keys for request-body schema override matching.""" + + operation_id = operation.operation.get("operationId") + keys = {_method_name(operation.group_name, operation.command_name), operation.operation_name} + if isinstance(operation_id, str) and operation_id: + keys.add(operation_id) + keys.add(_safe_identifier(operation_id)) + return keys + + +def _set_json_request_body_schema(operation: dict[str, Any], schema: dict[str, Any]) -> None: + request_body = operation.setdefault("requestBody", {}) + content = request_body.setdefault("content", {}) + media = content.setdefault("application/json", {}) + media["schema"] = schema + operation["x-tangle-cli-request-body-schema-override"] = True + + +def _apply_request_body_schema_overrides( + schema: dict[str, Any], + request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None, +) -> dict[str, Any]: + """Return schema with configured request-body schema overrides applied.""" + + overrides = _request_body_schema_mapping(request_body_schemas) + if not overrides: + return schema + + output = copy.deepcopy(schema) + operations = parsed_operations(output) + remaining = dict(overrides) + for operation in operations: + matching_keys = _operation_override_keys(operation) + for key in list(remaining): + if key in matching_keys: + _set_json_request_body_schema(operation.operation, remaining.pop(key)) + if remaining: + raise ValueError( + "Unknown request body schema override operation(s): " + ", ".join(sorted(remaining)) + ) + return output + + +@dataclass(frozen=True) +class _ModelExtensionRef: + """Import reference for one generated model extension class.""" + + module_name: str + class_name: str + alias: str + + +def _model_extension_modules( + model_extension_module: str | Sequence[str] | None, +) -> list[str]: + """Return ordered model extension modules, applying defaults first. + + ``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. + """ + + 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) + + 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 + + +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 _model_extension_mapping(module_name: str) -> dict[str, str]: + """Load and validate a MODEL_EXTENSIONS mapping from an extension module.""" + + 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 + + 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 + + +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.""" + + 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"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from pydantic import Field", + "", + "from tangle_cli.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) + exports.append(class_name) + if not isinstance(schema_def, dict) or schema_def.get("type") not in {"object", None} and "properties" not in schema_def: + 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):"]) + if not properties: + lines.append(" pass") + else: + for prop_name in sorted(properties): + field_name = _safe_identifier(prop_name) + if field_name != prop_name: + lines.append(f" {field_name}: Any = Field(default=None, alias={prop_name!r})") + 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("") + return "\n".join(lines) + +def _method_name(group_name: str, command_name: str) -> str: + return f"{_safe_identifier(group_name)}_{_safe_identifier(command_name)}" + + +def _validate_class_name(name: str) -> str: + """Validate a generated class name or extension class name.""" + + if not re.fullmatch(r"[A-Za-z_]\w*", name) or keyword.iskeyword(name): + raise ValueError(f"Invalid generated operations class name: {name!r}") + return name + + +def _param_signature( + parameters: list[Any], + has_request_body: bool, + *, + raw_body_override: bool = False, +) -> tuple[str, list[str], list[str], list[str], set[str], bool]: + required: list[Any] = [] + optional: list[Any] = [] + for parameter in parameters: + (required if parameter.required else optional).append(parameter) + ordered = required + optional + seen: set[str] = set() + signature_parts: list[str] = [] + path_names: list[str] = [] + query_names: list[str] = [] + body_names: list[str] = [] + required_body_names: set[str] = set() + for parameter in ordered: + name = _safe_identifier(parameter.local_name) + if name in seen: + continue + seen.add(name) + if parameter.required: + signature_parts.append(f"{name}: Any") + else: + signature_parts.append(f"{name}: Any = None") + if parameter.location == "path": + path_names.append(name) + elif parameter.location == "query": + query_names.append(name) + elif parameter.location == "body": + body_names.append(name) + if parameter.required: + required_body_names.add(name) + include_body = has_request_body and not body_names + if include_body: + body_annotation = "dict[str, Any] | None" if raw_body_override else "Any" + signature_parts.append(f"body: {body_annotation} = None") + return ", ".join(signature_parts), path_names, query_names, body_names, required_body_names, include_body + + +def _dict_literal(names: list[str]) -> str: + if not names: + return "None" + return "{" + ", ".join(f"{name!r}: {name}" for name in names) + "}" + + +def _body_dict_literal(names: list[str], required_names: set[str]) -> str: + if not names: + return "None" + optional_names = [name for name in names if name not in required_names] + if not optional_names: + return _dict_literal(names) + optional_literal = _dict_literal(optional_names) + optional_expr = f"key: value for key, value in {optional_literal}.items() if value is not None" + if not required_names: + return "{" + optional_expr + "}" + required_literal = _dict_literal([name for name in names if name in required_names]) + return "{" + f"**{required_literal}, **{{{optional_expr}}}" + "}" + + +def _validate_operation_path(path: str) -> None: + """Reject OpenAPI operation paths that could override the configured origin.""" + + parsed_path = urllib.parse.urlparse(path) + if parsed_path.scheme or parsed_path.netloc: + raise ValueError(f"OpenAPI operation path must be relative: {path!r}") + + +def generate_operations( + schema: dict[str, Any], + operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, + 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, +) -> str: + """Generate the static operation mixin class for parsed OpenAPI operations.""" + + operations_class_name = _validate_class_name(operations_class_name) + schema = _apply_request_body_schema_overrides(schema, request_body_schemas) + operations = parsed_operations(schema) + _, model_ref_aliases = _apply_model_aliases( + schema.get("components", {}).get("schemas", {}) or {}, + model_aliases, + ) + response_models = sorted({name for op in operations if (name := _response_model_name(op.operation, model_ref_aliases))}) + imports = ", ".join(response_models) + lines: list[str] = [ + '"""Generated static endpoint methods for the Tangle API.\n\nDo not edit by hand; run ``uv run python -m tangle_cli.openapi.codegen``.\n"""', + "", + "from __future__ import annotations", + "", + "from collections.abc import Mapping", + "from typing import TYPE_CHECKING, Any", + "", + ] + if imports: + lines.extend([f"from .models import {imports}", ""]) + + lines.extend([ + "", + f"class {operations_class_name}:", + " \"\"\"Generated checked-in methods for Tangle API operations.\"\"\"", + "", + " if TYPE_CHECKING:", + " def _request_json(", + " self,", + " method: str,", + " path: str,", + " *,", + " path_params: Mapping[str, Any] | None = None,", + " params: Mapping[str, Any] | None = None,", + " json_data: Any = None,", + " response_model: Any = None,", + " ) -> Any: ...", + "", + ]) + + used_methods: set[str] = set() + for operation in operations: + _validate_operation_path(operation.path) + method_name = _method_name(operation.group_name, operation.command_name) + if method_name in used_methods: + raise RuntimeError(f"duplicate generated method {method_name}") + used_methods.add(method_name) + signature, path_names, query_names, body_names, required_body_names, include_body = _param_signature( + list(operation.parameters), + operation.has_request_body, + 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_annotation = _response_return_annotation(operation.operation, model_ref_aliases) + if signature: + def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:" + else: + def_line = f" def {method_name}(self) -> {response_annotation}:" + lines.extend([ + def_line, + f" return self._request_json(", + f" {operation.method.upper()!r},", + f" {operation.path!r},", + f" path_params={_dict_literal(path_names)},", + f" params={_dict_literal(query_names)},", + ]) + if body_names: + lines.append(f" json_data={_body_dict_literal(body_names, required_body_names)},") + elif include_body: + lines.append(" json_data=body,") + else: + lines.append(" json_data=None,") + lines.extend([ + f" response_model={response_arg},", + " )", + "", + ]) + + lines.append(f"__all__ = {[operations_class_name]!r}") + lines.append("") + return "\n".join(lines) + + +def update_openapi_from_url( + openapi_url: str, + *, + destination: str | Path = DEFAULT_OPENAPI_PATH, +) -> Path: + """Fetch a remote OpenAPI JSON document and write it to *destination*.""" + + request = urllib.request.Request(openapi_url, headers={"User-Agent": "tangle-cli-codegen"}) + with urllib.request.urlopen(request, timeout=30) as response: + payload = response.read() + schema = json.loads(payload.decode("utf-8")) + return write_openapi_schema(schema, destination) + + +def update_openapi_from_backend( + *, + backend_path: str | Path = DEFAULT_BACKEND_PATH, + destination: str | Path = DEFAULT_OPENAPI_PATH, + database_uri: str | None = None, +) -> Path: + """Import the official backend FastAPI app and write its OpenAPI schema.""" + + schema = load_openapi_from_backend(backend_path, database_uri=database_uri) + return write_openapi_schema(schema, destination) + + +def load_openapi_from_backend( + backend_path: str | Path = DEFAULT_BACKEND_PATH, + *, + database_uri: str | None = None, +) -> dict[str, Any]: + """Return ``api_server_main.app.openapi()`` from a backend checkout. + + The backend creates a database engine at import time, so codegen points it at + a temporary SQLite database unless an explicit URI is supplied. + """ + + backend_dir = Path(backend_path).resolve() + if not (backend_dir / "api_server_main.py").exists(): + raise FileNotFoundError(f"{backend_dir} does not contain api_server_main.py") + + old_path = list(sys.path) + old_database_uri = os.environ.get("DATABASE_URI") + old_database_url = os.environ.get("DATABASE_URL") + old_modules = { + name: module + for name, module in sys.modules.items() + if name == "api_server_main" or name.startswith("cloud_pipelines_backend") + } + for name in list(old_modules): + sys.modules.pop(name, None) + + with tempfile.TemporaryDirectory(prefix="tangle-openapi-codegen-") as tmpdir: + os.environ["DATABASE_URI"] = database_uri or f"sqlite:///{Path(tmpdir) / 'openapi_codegen.sqlite'}" + os.environ.pop("DATABASE_URL", None) + sys.path.insert(0, str(backend_dir)) + try: + api_server_main = importlib.import_module("api_server_main") + schema = api_server_main.app.openapi() + finally: + sys.path[:] = old_path + for name in [ + name + for name in sys.modules + if name == "api_server_main" or name.startswith("cloud_pipelines_backend") + ]: + sys.modules.pop(name, None) + sys.modules.update(old_modules) + if old_database_uri is None: + os.environ.pop("DATABASE_URI", None) + else: + os.environ["DATABASE_URI"] = old_database_uri + if old_database_url is None: + os.environ.pop("DATABASE_URL", None) + else: + os.environ["DATABASE_URL"] = old_database_url + + if not isinstance(schema, dict) or "paths" not in schema: + raise ValueError(f"Backend at {backend_dir} did not produce an OpenAPI paths object") + return schema + + +def write_openapi_schema(schema: dict[str, Any], destination: str | Path = DEFAULT_OPENAPI_PATH) -> Path: + """Write *schema* as the checked-in OpenAPI snapshot.""" + + if not isinstance(schema, dict) or "paths" not in schema: + raise ValueError("OpenAPI schema did not contain paths") + destination_path = Path(destination) + destination_path.parent.mkdir(parents=True, exist_ok=True) + destination_path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return destination_path + + +def generate( + openapi_path: str | Path | None = None, + 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]]: + schema = load_openapi_schema(openapi_path) + output_dir = Path(generated_dir) + output_dir.mkdir(parents=True, exist_ok=True) + generated_files = [ + output_dir / "__init__.py", + output_dir / "models.py", + output_dir / "operations.py", + ] + generated_files[0].write_text( + '"""Generated OpenAPI support modules."""\n', + encoding="utf-8", + ) + generated_files[1].write_text( + generate_models( + schema, + model_extension_module=model_extension_module, + model_aliases=model_aliases, + ), + encoding="utf-8", + ) + generated_files[2].write_text( + generate_operations( + schema, + operations_class_name=operations_class_name, + model_aliases=model_aliases, + request_body_schemas=request_body_schemas, + ), + encoding="utf-8", + ) + return schema, generated_files + + +def _default_snapshot_source() -> str: + if DEFAULT_OPENAPI_PATH.exists(): + return f"snapshot: {_display_path(DEFAULT_OPENAPI_PATH)}" + return f"snapshot: {DEFAULT_OPENAPI_RESOURCE_PACKAGE}/{DEFAULT_OPENAPI_RESOURCE_NAME}" + + +def _display_path(path: str | Path) -> str: + resolved = Path(path).resolve() + try: + return str(resolved.relative_to(Path.cwd().resolve())) + except ValueError: + return str(path) + + +def _print_summary( + *, + source: str, + openapi_path: str | Path, + generated_files: list[Path], + schema: dict[str, Any], + wrote_openapi: bool, +) -> None: + print(f"Loaded OpenAPI from {source}") + if wrote_openapi: + print(f"Wrote {_display_path(openapi_path)}") + for path in generated_files: + print(f"Wrote {_display_path(path)}") + print(f"Generated {len(parsed_operations(schema))} operations from {len(schema.get('paths', {}))} paths") + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--openapi", + default=None, + help=( + "Path to openapi.json. Defaults to the official snapshot in " + "packages/tangle-api/src/tangle_api/schema/openapi.json, or the " + "packaged tangle_api.schema snapshot when installed." + ), + ) + parser.add_argument( + "--out", + default=str(_GENERATED_DIR), + help="Generated support module directory (default: packages/tangle-api/src/tangle_api/generated).", + ) + parser.add_argument( + "--operations-class-name", + default=DEFAULT_OPERATIONS_CLASS_NAME, + help=( + "Class name to generate in operations.py " + 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", + default=None, + help=( + "Expose a stable public model class from one or more source schemas, " + "using PublicModel=SourceSchema[,OtherSourceSchema]. Repeat for " + "multiple aliases. The built-in ComponentSpec alias is applied first " + "unless an empty string is passed to disable defaults." + ), + ) + parser.add_argument( + "--request-body-schema", + action="append", + default=None, + help=( + "Override an operation JSON request-body schema using " + "OperationId={...json schema...}. OperationId may be the OpenAPI " + "operationId, generated method name, or group.command name. Repeat " + "for multiple operations." + ), + ) + parser.add_argument( + "--request-body-schema-file", + action="append", + default=None, + help=( + "Override an operation JSON request-body schema from a JSON file " + "using OperationId=path/to/schema.json. Repeat for multiple operations." + ), + ) + parser.add_argument( + "--openapi-url", + default=None, + help="Remote OpenAPI JSON URL to fetch before regenerating.", + ) + parser.add_argument( + "--backend-path", + default=None, + help=( + "Backend checkout/submodule path to import for OpenAPI generation " + f"(default: {_display_path(DEFAULT_BACKEND_PATH)})." + ), + ) + parser.add_argument( + "--backend-database-uri", + default=None, + help="Database URI used while importing the backend app; defaults to a temporary SQLite DB.", + ) + parser.add_argument( + "--from-snapshot", + action="store_true", + help="Regenerate support modules from the official API-package openapi.json snapshot.", + ) + 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)) + if not request_body_schema_overrides: + request_body_schema_overrides = None + except ValueError as exc: + parser.error(str(exc)) + source_count = sum(bool(value) for value in (args.openapi_url, args.backend_path, args.from_snapshot)) + if source_count > 1: + parser.error("choose only one OpenAPI source: --openapi-url, --backend-path, or --from-snapshot") + + openapi_path = args.openapi or DEFAULT_OPENAPI_PATH + wrote_openapi = False + if args.openapi_url: + update_openapi_from_url(args.openapi_url, destination=openapi_path) + source = f"URL: {args.openapi_url}" + wrote_openapi = True + elif args.from_snapshot: + openapi_path = args.openapi + source = f"snapshot: {_display_path(openapi_path)}" if openapi_path else _default_snapshot_source() + else: + backend_path = Path(args.backend_path) if args.backend_path else DEFAULT_BACKEND_PATH + if not (backend_path / "api_server_main.py").exists(): + if args.backend_path: + parser.exit(1, f"Backend source not found: {_display_path(backend_path)}\n") + parser.exit( + 1, + "Default backend submodule not found. Run: git submodule update --init --recursive\n", + ) + update_openapi_from_backend( + backend_path=backend_path, + destination=openapi_path, + database_uri=args.backend_database_uri, + ) + source = f"backend: {_display_path(backend_path)}" + wrote_openapi = True + + schema, generated_files = generate( + 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, + ) + _print_summary( + source=source, + openapi_path=openapi_path or DEFAULT_OPENAPI_PATH, + generated_files=generated_files, + schema=schema, + wrote_openapi=wrote_openapi, + ) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/packages/tangle-cli/src/tangle_cli/openapi/parser.py b/packages/tangle-cli/src/tangle_cli/openapi/parser.py new file mode 100644 index 0000000..b5ebcd8 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/openapi/parser.py @@ -0,0 +1,77 @@ +"""Offline OpenAPI loading helpers used by generated-client codegen. + +The runtime client does not import this module. It exists so expanding the +checked-in generated client is a deterministic local operation over the +checked-in API-package ``openapi.json`` snapshot. +""" + +from __future__ import annotations + +import json +from importlib import resources +from pathlib import Path +from typing import Any + +from tangle_cli.api_schema import OperationCommand, operation_commands + +_REPO_ROOT = Path(__file__).resolve().parents[5] +DEFAULT_OPENAPI_PATH = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" / "schema" / "openapi.json" +DEFAULT_OPENAPI_RESOURCE_PACKAGE = "tangle_api.schema" +DEFAULT_OPENAPI_RESOURCE_NAME = "openapi.json" +_FALLBACK_OPENAPI_RESOURCE_PACKAGES = (DEFAULT_OPENAPI_RESOURCE_PACKAGE, "tangle_cli.openapi") + + +def _load_json_file(schema_path: Path) -> dict[str, Any]: + with schema_path.open("r", encoding="utf-8") as f: + schema = json.load(f) + if not isinstance(schema, dict) or "paths" not in schema: + raise ValueError(f"{schema_path} does not look like an OpenAPI schema") + return schema + + +def _load_default_openapi_schema() -> dict[str, Any]: + if DEFAULT_OPENAPI_PATH.exists(): + return _load_json_file(DEFAULT_OPENAPI_PATH) + + schema_text = None + schema_package = DEFAULT_OPENAPI_RESOURCE_PACKAGE + last_error: Exception | None = None + for package in _FALLBACK_OPENAPI_RESOURCE_PACKAGES: + try: + schema_text = ( + resources.files(package) + .joinpath(DEFAULT_OPENAPI_RESOURCE_NAME) + .read_text(encoding="utf-8") + ) + schema_package = package + break + except (FileNotFoundError, ModuleNotFoundError) as exc: + 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." + ) from last_error + + schema = json.loads(schema_text) + if not isinstance(schema, dict) or "paths" not in schema: + raise ValueError( + f"{schema_package}/{DEFAULT_OPENAPI_RESOURCE_NAME} " + "does not look like an OpenAPI schema" + ) + return schema + + +def load_openapi_schema(path: str | Path | None = None) -> dict[str, Any]: + """Load a Tangle OpenAPI schema from disk.""" + + if path is None: + return _load_default_openapi_schema() + return _load_json_file(Path(path)) + + +def parsed_operations(schema: dict[str, Any] | None = None) -> list[OperationCommand]: + """Return normalized operations using the same parser as the dynamic CLI.""" + + return operation_commands(schema or load_openapi_schema()) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py new file mode 100644 index 0000000..39d3aa5 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py @@ -0,0 +1,720 @@ +"""Pipeline dehydration helpers for hydrated Tangle pipeline specs. + +The dehydrator is the inverse companion to :mod:`tangle_cli.pipeline_hydrator`: +it replaces full ``componentRef.spec`` blocks with portable digest/name/url/file +references, and can export a hydrated pipeline into a Jinja2 template + config +pair. The code is intentionally native-free; downstream packages can provide a +client for component-library existence checks and URI reader/writer hooks for +schemes such as ``gs://`` without this module importing those SDKs. +""" + +from __future__ import annotations + +import copy +import json +import os +import re +import textwrap +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from . import utils +from .api_transport import DEFAULT_API_URL +from .handler import TangleCliHandler +from .logger import Logger, get_default_logger +from .pipeline_hydrator import PipelineHydrator, ResolverContext, UriReader, UriWriter + +PATH_SEPARATOR = "|" # Use | as separator since task names can contain dots. + + +@dataclass(frozen=True) +class Jinja2ExportResult: + """Result of exporting a pipeline to Jinja2 templates.""" + + main_template_path: Path + config_file_path: Path + subtemplates_count: int + top_level_params_count: int + subtemplate_paths: list[Path] + + +class DehydrateChoice: + """Constants for dehydration choices. + + Lowercase values apply to the current component. Downstream interactive + callers may use uppercase values to remember a choice for the same digest. + """ + + DIGEST = "d" + NAME = "n" + URL = "u" + FILE = "f" + KEEP = "k" + AUTO = "a" + + +class PipelineDehydrator(TangleCliHandler): + """Dehydrate pipeline YAML by replacing full component specs with refs. + + Supported choices: + - ``DIGEST``: replace with ``componentRef.digest`` + - ``NAME``: replace with ``componentRef.name`` + - ``URL``: replace with ``componentRef.url`` when a canonical URL exists + - ``FILE``: extract the component spec and reference it by URL + - ``KEEP``: preserve the full spec + - ``AUTO``: URL if canonical, else digest when the optional client can find + the component in the library, else file extraction + + URI I/O is delegated through the same native-free hooks as the hydrator. + OSS registers no cloud schemes by default; downstream packages can pass or + register URI hooks for ``gs://`` or other backends. + """ + + def __init__( + self, + remembered_choices: Mapping[str, str] | None = None, + components_dir: Path | str | None = None, + output_file: Path | str | None = None, + client: Any = None, + interactive: bool = False, + logger: Logger | None = None, + component_extension: str | None = None, + *, + base_url: str | None = None, + uri_readers: Mapping[str, UriReader] | None = None, + uri_writers: Mapping[str, UriWriter] | None = None, + ) -> None: + super().__init__( + client=client, + logger=logger, + base_url=base_url or DEFAULT_API_URL, + ) + self.remembered_choices = dict(remembered_choices or {}) + self.output_file = output_file + self.component_extension = component_extension or ".yaml" + + self._components_dir_explicit = components_dir is not None + if components_dir is not None: + self.components_dir: Path | str = components_dir + elif output_file is not None: + self.components_dir = self._join_destination(self._destination_parent(output_file), "components") + else: + self.components_dir = Path("components") + + self.interactive = interactive + self._saved_components: dict[str, Path | str] = {} + self._current_reference_file: Path | str | None = output_file + self._io = PipelineHydrator( + enable_resolution=False, + logger=self.log, + base_url=self.base_url, + uri_readers=uri_readers, + uri_writers=uri_writers, + ) + + def _is_auto_mode(self) -> bool: + """Return True when any remembered choice asks for auto mode.""" + + return DehydrateChoice.AUTO in self.remembered_choices.values() + + @staticmethod + def _uri_scheme(value: Path | str | None) -> str | None: + if value is None: + return None + return PipelineHydrator._uri_scheme(str(value)) + + @classmethod + def _is_local_destination(cls, value: Path | str | None) -> bool: + scheme = cls._uri_scheme(value) + return scheme is None or scheme == "file" + + @classmethod + def _destination_parent(cls, value: Path | str) -> Path | str: + value_str = str(value) + scheme = cls._uri_scheme(value) + if scheme and scheme != "file": + return value_str.rsplit("/", 1)[0] if "/" in value_str else value_str + path = Path(value_str[7:] if value_str.startswith("file://") else value_str) + return path.parent + + @classmethod + def _join_destination(cls, parent: Path | str, filename: str) -> Path | str: + if cls._uri_scheme(parent) and cls._uri_scheme(parent) != "file": + return f"{str(parent).rstrip('/')}/{filename}" + return Path(parent) / filename + + def _resolver_context(self, uri: str, kind: str) -> ResolverContext: + return self._io.make_resolver_context(self._uri_scheme(uri) or kind, uri, kind, None) + + def _read_text(self, source: Path | str, *, kind: str = "pipeline") -> str: + return self._io._read_uri_text(str(source), kind, self._resolver_context(str(source), kind)) or "" + + def _write_text(self, destination: Path | str, content: str, *, kind: str = "output") -> None: + self._io._write_uri_text(str(destination), content, self._resolver_context(str(destination), kind)) + + def load_file(self, input_file: Path | str) -> dict[str, Any]: + """Read a local or URI pipeline YAML file through the registered hooks.""" + + data = yaml.safe_load(self._read_text(input_file, kind="pipeline")) + return data or {} + + def write_file(self, data: dict[str, Any], output_file: Path | str | None = None) -> None: + """Write pipeline YAML to a local path or URI through registered hooks.""" + + destination = output_file or self.output_file + if destination is None: + raise ValueError("output_file is required") + self._write_text(destination, utils.dump_yaml(data), kind="output") + + def dehydrate_file( + self, + input_file: Path | str, + output_file: Path | str | None = None, + ) -> dict[str, Any]: + """Read, dehydrate, and write a pipeline YAML file. + + Both input and output support local paths and any URI schemes provided + by registered/passed hydrator URI hooks. + """ + + previous_output = self.output_file + previous_reference = self._current_reference_file + previous_components_dir = self.components_dir + if output_file is not None: + self.output_file = output_file + self._current_reference_file = output_file + if not self._components_dir_explicit: + self.components_dir = self._join_destination(self._destination_parent(output_file), "components") + try: + data = self.load_file(input_file) + output = self.dehydrate(data) + self.write_file(output, output_file) + return output + finally: + self.output_file = previous_output + self._current_reference_file = previous_reference + self.components_dir = previous_components_dir + + def _auto_dehydrate_choice( + self, + canonical_url: str | None, + resolved_digest: str, + name: str, + _spec: dict[str, Any], + path: str, + ) -> str: + """Determine Auto mode outcome: ``url``, ``digest``, or ``file``.""" + + self.log.info(f" Auto: '{name}' at {path} (digest: {resolved_digest[:16]}...)") + if canonical_url: + self.log.info(" Auto: has canonical URL -> url ref") + return "url" + if not resolved_digest or resolved_digest == "unknown": + self.log.info(" Auto: no digest -> file") + return "file" + try: + client = self._get_client() + except (Exception, SystemExit): + self.log.info(" Auto: no API client available -> file") + return "file" + if client is None: + self.log.info(" Auto: no API client provided -> file") + return "file" + try: + client.get_component_spec(resolved_digest) + self.log.info(f" Auto: digest {resolved_digest[:16]} found in library -> digest ref") + return "digest" + except Exception: + self.log.info(f" Auto: digest {resolved_digest[:16]} not in library -> file") + return "file" + + def _prompt_choice(self, name: str, digest: str, canonical_url: str | None, path: str) -> str: + self.log.info(f"\n📦 Found componentRef at: {path}") + self.log.info(f" Name: {name}") + self.log.info(f" Digest: {digest[:16]}...") + if canonical_url: + self.log.info(f" URL: {canonical_url}") + self.log.info(" Options:") + self.log.info(f" [{DehydrateChoice.DIGEST}] Replace with componentRef.digest") + self.log.info(f" [{DehydrateChoice.NAME}] Replace with componentRef.name") + if canonical_url: + self.log.info(f" [{DehydrateChoice.URL}] Replace with componentRef.url") + self.log.info(f" [{DehydrateChoice.FILE}] Extract to file and use file:// URL") + self.log.info(f" [{DehydrateChoice.AUTO}] Auto: URL if present, else digest if in library, else file") + self.log.info(f" [{DehydrateChoice.KEEP}] Leave as is (keep full spec)") + self.log.info(f" [{DehydrateChoice.DIGEST.upper()}] Always replace this component with digest") + self.log.info(f" [{DehydrateChoice.NAME.upper()}] Always replace this component with name") + if canonical_url: + self.log.info(f" [{DehydrateChoice.URL.upper()}] Always replace this component with URL") + self.log.info(f" [{DehydrateChoice.FILE.upper()}] Always extract to file") + choice = input(f" Choice [{DehydrateChoice.AUTO}]: ").strip() or DehydrateChoice.AUTO + return choice + + def _process_task( + self, + task_name: str, + task_data: dict[str, Any], + path: str, + base_dir: Path | None = None, + _recursive_params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Dehydrate a single non-subgraph task's componentRef.""" + + del task_name, base_dir, _recursive_params + if not isinstance(task_data, dict) or "componentRef" not in task_data: + return task_data + + component_ref = task_data["componentRef"] + if not isinstance(component_ref, dict) or "spec" not in component_ref: + return task_data + + name, digest = utils.get_component_ref_info(component_ref) + spec = component_ref.get("spec", {}) + if not isinstance(spec, dict): + return task_data + + canonical_url = spec.get("metadata", {}).get("annotations", {}).get("canonical_location") + resolved_digest = component_ref.get("digest") or utils.compute_spec_digest(spec) + choice = ( + self.remembered_choices.get(resolved_digest) + or self.remembered_choices.get(digest) + or self.remembered_choices.get("") + ) + if choice: + if choice == DehydrateChoice.URL and not canonical_url: + choice = DehydrateChoice.DIGEST + if choice != DehydrateChoice.AUTO: + self.log.info(f" Using remembered choice: {choice}") + elif self.interactive: + choice = self._prompt_choice(name, digest, canonical_url, path) + if choice == DehydrateChoice.DIGEST.upper(): + self.remembered_choices[resolved_digest] = DehydrateChoice.DIGEST + choice = DehydrateChoice.DIGEST + elif choice == DehydrateChoice.NAME.upper(): + self.remembered_choices[resolved_digest] = DehydrateChoice.NAME + choice = DehydrateChoice.NAME + elif choice == DehydrateChoice.URL.upper() and canonical_url: + self.remembered_choices[resolved_digest] = DehydrateChoice.URL + choice = DehydrateChoice.URL + elif choice == DehydrateChoice.FILE.upper(): + self.remembered_choices[resolved_digest] = DehydrateChoice.FILE + choice = DehydrateChoice.FILE + else: + choice = DehydrateChoice.AUTO + + new_task = {k: v for k, v in task_data.items() if k != "componentRef"} + + if choice == DehydrateChoice.AUTO: + effective = self._auto_dehydrate_choice(canonical_url, resolved_digest, name, spec, path) + if effective == "url": + new_task["componentRef"] = {"url": canonical_url} + self.log.info(" → Auto: Replaced with componentRef.url") + elif effective == "digest": + new_task["componentRef"] = {"digest": resolved_digest} + self.log.info(" → Auto: Replaced with componentRef.digest (found in library)") + else: + file_url = self._save_component_to_file(name, resolved_digest, spec) + new_task["componentRef"] = {"url": file_url} + self.log.info(" → Auto: Extracted to file (no URL, not in library or no client)") + elif choice == DehydrateChoice.DIGEST: + new_task["componentRef"] = {"digest": resolved_digest} + self.log.info(" → Replaced with componentRef.digest") + elif choice == DehydrateChoice.NAME: + new_task["componentRef"] = {"name": name} + self.log.info(" → Replaced with componentRef.name") + elif choice == DehydrateChoice.URL and canonical_url: + new_task["componentRef"] = {"url": canonical_url} + self.log.info(" → Replaced with componentRef.url") + elif choice == DehydrateChoice.FILE: + file_url = self._save_component_to_file(name, resolved_digest, spec) + new_task["componentRef"] = {"url": file_url} + self.log.info(f" → Extracted to {file_url}") + else: + new_task["componentRef"] = component_ref + self.log.info(" → Kept as componentRef (full spec)") + + return new_task + + def _safe_filename(self, name: str, fallback: str = "component") -> str: + safe_name = name.lower().replace(" ", "_").replace("-", "_") + safe_name = "".join(c for c in safe_name if c.isalnum() or c == "_") + return safe_name or fallback + + def _save_component_to_file(self, name: str, digest: str, spec: dict[str, Any]) -> str: + """Save a component spec once and return a reference URL for this file.""" + + if digest not in self._saved_components: + filename = f"{self._safe_filename(name)}{self.component_extension}" + destination = self._join_destination(self.components_dir, filename) + self._write_text(destination, utils.dump_yaml(spec), kind="component") + if self._is_local_destination(destination): + destination_text = str(destination) + if destination_text.startswith("file://"): + destination_text = destination_text[7:] + destination = Path(destination_text).resolve() + self._saved_components[digest] = destination + return self._make_ref_url(self._saved_components[digest]) + + def _make_ref_url(self, target: Path | str) -> str: + """Create a componentRef URL for a saved target.""" + + if not self._is_local_destination(target): + return str(target) + return self._make_file_url(Path(str(target)[7:] if str(target).startswith("file://") else str(target))) + + def _make_file_url(self, target_path: Path) -> str: + """Create a file:// URL relative to the current reference file.""" + + ref_file = self._current_reference_file or self.output_file + if ref_file and self._is_local_destination(ref_file): + ref_str = str(ref_file) + ref_path = Path(ref_str[7:] if ref_str.startswith("file://") else ref_str) + ref_dir = ref_path.parent.resolve() + rel = os.path.relpath(target_path.resolve(), ref_dir) + return f"file://./{rel}" + return f"file://{target_path.resolve()}" + + @staticmethod + def _relativize_file_urls(spec: dict[str, Any], reference_dir: Path) -> None: + """Convert absolute file:// URLs in a spec's tasks relative to reference_dir.""" + + tasks = spec.get("implementation", {}).get("graph", {}).get("tasks", {}) + resolved_ref_dir = reference_dir.resolve() + for task_data in tasks.values(): + if not isinstance(task_data, dict): + continue + component_ref = task_data.get("componentRef") + if not isinstance(component_ref, dict) or "url" not in component_ref: + continue + url = component_ref["url"] + if not isinstance(url, str) or not url.startswith("file:///"): + continue + abs_path = Path(url[7:]) + rel = os.path.relpath(abs_path, resolved_ref_dir) + component_ref["url"] = f"file://./{rel}" + + def _subgraph_destination(self, filename: str) -> Path | str: + if self.output_file is not None: + subgraph_dir = self._join_destination(self._destination_parent(self.output_file), "subgraphs") + else: + subgraph_dir = self._join_destination(self.components_dir, "subgraphs") + return self._join_destination(subgraph_dir, filename) + + def _extract_subgraphs_to_files(self, data: dict[str, Any]) -> dict[str, Any]: + """Extract subgraph specs to YAML files and replace them with URL refs.""" + + queue = _build_subgraph_processing_queue(data) + subgraph_counter = 0 + + for depth, path in queue: + if depth == 0: + continue + + result = _get_subgraph_by_path(data, path) + if not result: + continue + component_ref, spec = result + + spec_name = spec.get("name", "subgraph") + filename = f"{self._safe_filename(str(spec_name), 'subgraph')}_{subgraph_counter}{self.component_extension}" + subgraph_counter += 1 + destination = self._subgraph_destination(filename) + + original_ref = self._current_reference_file + self._current_reference_file = destination + try: + spec_to_write = utils.traverse_pipeline_tasks(copy.deepcopy(spec), str(spec_name), self._process_task) + finally: + self._current_reference_file = original_ref + + if self._is_local_destination(destination): + destination_text = str(destination) + if destination_text.startswith("file://"): + destination_text = destination_text[7:] + destination_path = Path(destination_text) + self._relativize_file_urls(spec_to_write, destination_path.parent) + self._write_text(destination_path, utils.dump_yaml(spec_to_write) + "\n", kind="subgraph") + component_url = f"file://{destination_path.resolve()}" + else: + self._write_text(destination, utils.dump_yaml(spec_to_write) + "\n", kind="subgraph") + component_url = str(destination) + + self.log.info(f" 📦 Extracted subgraph '{spec_name}' -> {filename}") + component_ref.clear() + component_ref["url"] = component_url + + if self.output_file and self._is_local_destination(self.output_file): + output_file_text = str(self.output_file) + if output_file_text.startswith("file://"): + output_file_text = output_file_text[7:] + output_path = Path(output_file_text) + self._relativize_file_urls(data, output_path.parent) + + return data + + def dehydrate(self, data: dict[str, Any]) -> dict[str, Any]: + """Return a dehydrated copy of *data* according to configured choices.""" + + working = copy.deepcopy(data) + if self.remembered_choices.get("") == DehydrateChoice.AUTO: + self._extract_subgraphs_to_files(working) + + pipeline_name = working.get("name", "pipeline") + return utils.traverse_pipeline_tasks(working, str(pipeline_name), self._process_task) + + def export_to_jinja2( + self, + data: dict[str, Any], + output_file: Path, + jinja2_path: Path, + ) -> Jinja2ExportResult: + """Dehydrate a pipeline and export it to Jinja2 template files.""" + + previous_output = self.output_file + previous_reference = self._current_reference_file + previous_components_dir = self.components_dir + self.output_file = output_file + self._current_reference_file = output_file + if not self._components_dir_explicit: + self.components_dir = self._join_destination(self._destination_parent(output_file), "components") + try: + output_yaml = self.dehydrate(data) + finally: + self.output_file = previous_output + self._current_reference_file = previous_reference + self.components_dir = previous_components_dir + + jinja2_path.parent.mkdir(parents=True, exist_ok=True) + output_file.parent.mkdir(parents=True, exist_ok=True) + + base_name = jinja2_path.stem + if base_name.endswith(".yaml"): + base_name = base_name[:-5] + + top_level_defaults = _extract_input_defaults(output_yaml) + modified_data, subtemplates = _process_subgraphs_to_subtemplates(output_yaml, self.log) + template_data = _replace_input_defaults_with_placeholders(modified_data) + + subtemplate_paths: list[Path] = [] + for subtemplate_id, subtemplate_info in subtemplates.items(): + subtemplate_file = jinja2_path.parent / f"{base_name}_{subtemplate_id}.yaml.j2" + subtemplate_yaml = utils.dump_yaml(subtemplate_info["spec"]) + + path_depth = subtemplate_info["path"].count(PATH_SEPARATOR) // 2 + indent = " " * (12 * path_depth) + subtemplate_yaml = textwrap.indent(subtemplate_yaml, indent) + subtemplate_yaml = _convert_templateid_to_includes(subtemplate_yaml, subtemplates, base_name) + + subtemplate_file.write_text(subtemplate_yaml, encoding="utf-8") + subtemplate_paths.append(subtemplate_file) + self.log.info(f" 📄 Wrote {subtemplate_file.name}") + + main_yaml = utils.dump_yaml(template_data) + main_yaml = _convert_templateid_to_includes(main_yaml, subtemplates, base_name) + jinja2_path.write_text(main_yaml, encoding="utf-8") + + try: + rel_template_path = jinja2_path.relative_to(output_file.parent) + except ValueError: + rel_template_path = jinja2_path + + config_data: dict[str, Any] = {"template_file": str(rel_template_path), **top_level_defaults} + output_file.write_text(utils.dump_yaml(config_data), encoding="utf-8") + + return Jinja2ExportResult( + main_template_path=jinja2_path, + config_file_path=output_file, + subtemplates_count=len(subtemplates), + top_level_params_count=len(top_level_defaults), + subtemplate_paths=subtemplate_paths, + ) + + +def _extract_input_defaults(data: dict[str, Any]) -> dict[str, Any]: + """Extract default values from top-level inputs.""" + + defaults: dict[str, Any] = {} + inputs = data.get("inputs", []) + if isinstance(inputs, list): + for input_spec in inputs: + if isinstance(input_spec, dict) and "name" in input_spec and "default" in input_spec: + defaults[_sanitize_variable_name(str(input_spec["name"]))] = input_spec["default"] + elif isinstance(inputs, dict): + for name, input_def in inputs.items(): + if isinstance(input_def, dict) and "default" in input_def: + defaults[_sanitize_variable_name(str(name))] = input_def["default"] + return defaults + + +def _replace_input_defaults_with_placeholders(data: dict[str, Any]) -> dict[str, Any]: + """Replace top-level input defaults with Jinja2 placeholders.""" + + modified = copy.deepcopy(data) + inputs = modified.get("inputs", []) + if isinstance(inputs, list): + for input_spec in inputs: + if isinstance(input_spec, dict) and "name" in input_spec and "default" in input_spec: + var_name = _sanitize_variable_name(str(input_spec["name"])) + input_spec["default"] = "{{ " + var_name + " }}" + elif isinstance(inputs, dict): + for name, input_def in inputs.items(): + if isinstance(input_def, dict) and "default" in input_def: + var_name = _sanitize_variable_name(str(name)) + input_def["default"] = "{{ " + var_name + " }}" + return modified + + +def _sanitize_variable_name(name: str) -> str: + """Convert a name to a valid Jinja2 variable name.""" + + sanitized = re.sub(r"[^\w]", "_", name.lower()) + sanitized = re.sub(r"_+", "_", sanitized) + return sanitized.strip("_") + + +def _convert_templateid_to_includes( + yaml_text: str, + subtemplates: Mapping[str, Mapping[str, Any]], + base_name: str, +) -> str: + """Convert templateId markers in YAML to Jinja2 include syntax.""" + + def replace_with_include(match: re.Match[str], template_file: str) -> str: + name_value = match.group(1).strip() + if not (name_value.startswith("'") or name_value.startswith('"')): + name_value = f"'{name_value}'" + return f"{{% with _subgraph_name = {name_value} %}}{{% include '{template_file}' %}}{{% endwith %}}" + + for subtemplate_id in subtemplates: + template_filename = f"{base_name}_{subtemplate_id}.yaml.j2" + yaml_text = re.sub( + rf"^\s*templateId:\s*{re.escape(subtemplate_id)}\s*\n\s*_subgraph_name:\s*(.+?)\s*$", + lambda m: replace_with_include(m, template_filename), + yaml_text, + flags=re.MULTILINE, + ) + return yaml_text + + +def _build_subgraph_processing_queue(data: dict[str, Any]) -> list[tuple[int, str]]: + """Build subgraph paths ordered deepest-first.""" + + results: list[tuple[int, str]] = [] + stack: list[tuple[dict[str, Any], str, int]] = [(data, "", 0)] + + while stack: + spec, current_path, depth = stack.pop() + spec_name = spec.get("name", "unnamed") + path = f"{current_path}{PATH_SEPARATOR}{spec_name}" if current_path else str(spec_name) + results.append((depth, path)) + + tasks = spec.get("implementation", {}).get("graph", {}).get("tasks", {}) + for task_name, task_data in tasks.items(): + if not isinstance(task_data, dict): + continue + component_ref = task_data.get("componentRef") + if not isinstance(component_ref, dict): + continue + nested_spec = component_ref.get("spec", {}) + if utils.is_subgraph_spec(nested_spec): + stack.append((nested_spec, f"{path}{PATH_SEPARATOR}{task_name}", depth + 1)) + + return sorted(results, key=lambda item: (-item[0], item[1])) + + +def _get_task_component_ref(spec: dict[str, Any], task_name: str) -> tuple[dict[str, Any], dict[str, Any]]: + """Return ``(componentRef, nested_spec)`` for a task in a spec graph.""" + + tasks = spec.get("implementation", {}).get("graph", {}).get("tasks", {}) + task_data = tasks.get(task_name, {}) + component_ref = task_data.get("componentRef", {}) + nested_spec = component_ref.get("spec", {}) if isinstance(component_ref, dict) else {} + return component_ref, nested_spec + + +def _get_subgraph_by_path(data: dict[str, Any], path: str) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Resolve a subgraph's componentRef and spec by queue path.""" + + path_parts = path.split(PATH_SEPARATOR) + if len(path_parts) < 3: + return None + current_spec = data + for i in range(1, len(path_parts) - 2, 2): + task_name = path_parts[i] + _, current_spec = _get_task_component_ref(current_spec, task_name) + + parent_task_name = path_parts[-2] + component_ref, spec = _get_task_component_ref(current_spec, parent_task_name) + if not spec: + return None + return component_ref, spec + + +def _spec_hash(spec: dict[str, Any]) -> str: + """Compute a hash key for a spec dictionary, ignoring top-level name.""" + + spec_for_hash = {k: v for k, v in spec.items() if k != "name"} + return json.dumps(spec_for_hash, sort_keys=True) + + +def _process_subgraphs_to_subtemplates( + data: dict[str, Any], + logger: Logger | None = None, +) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + """Extract subgraph specs into reusable subtemplate records.""" + + log = logger or get_default_logger() + working = copy.deepcopy(data) + queue = _build_subgraph_processing_queue(working) + subtemplates_by_hash: dict[str, dict[str, Any]] = {} + subtemplate_counter = 0 + + for depth, path in queue: + if depth == 0: + continue + + result = _get_subgraph_by_path(working, path) + if not result: + continue + component_ref, spec = result + + spec_key = _spec_hash(spec) + spec_name = spec.get("name", "unnamed") + if spec_key in subtemplates_by_hash: + subtemplate_id = subtemplates_by_hash[spec_key]["id"] + log.info(f" ♻️ Reusing {subtemplate_id} for '{spec_name}'") + else: + subtemplate_id = f"subtemplate_{subtemplate_counter}" + subtemplate_counter += 1 + spec_copy = copy.deepcopy(spec) + if "name" in spec_copy: + spec_copy["name"] = "{{ _subgraph_name }}" + subtemplates_by_hash[spec_key] = {"id": subtemplate_id, "spec": spec_copy, "path": path} + log.info(f" 📦 Created {subtemplate_id} for '{spec_name}'") + + component_ref["spec"] = {"templateId": subtemplate_id, "_subgraph_name": spec_name} + + subtemplates = { + info["id"]: {"spec": info["spec"], "path": info["path"]} + for info in subtemplates_by_hash.values() + } + return working, subtemplates + + +__all__ = [ + "DehydrateChoice", + "Jinja2ExportResult", + "PipelineDehydrator", + "PATH_SEPARATOR", + "_build_subgraph_processing_queue", + "_convert_templateid_to_includes", + "_extract_input_defaults", + "_get_subgraph_by_path", + "_process_subgraphs_to_subtemplates", + "_replace_input_defaults_with_placeholders", + "_sanitize_variable_name", +] diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py new file mode 100644 index 0000000..b05bffa --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -0,0 +1,1785 @@ +"""Pipeline hydrator for expanding local Tangle pipeline YAML files. + +This module is intentionally a close OSS port of +``tangle_deploy.pipeline_hydrator``. The generic reference-resolution code and +method names are preserved where possible so future upstream diffs are easy to +compare. Provider-specific infrastructure integrations are omitted, and +Docker/from-container materialization paths raise explicit unsupported errors. +""" + +from __future__ import annotations + +import copy +import json +import re +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from inspect import Parameter, signature +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +from . import utils +from .api_transport import DEFAULT_TIMEOUT_SECONDS +from .component_generator import ComponentGenerator +from .handler import TangleCliHandler +from .hydration_trust import is_trusted_python_source, trusted_python_source_guidance +from .logger import Logger +from .utils import add_official_prefix + +if TYPE_CHECKING: + from .client import TangleApiClient + from .models import ComponentInfo + + +class HydrationError(ValueError): + """Raised when a pipeline cannot be hydrated safely in OSS mode.""" + + +class UnsupportedHydrationFeatureError(HydrationError): + """Raised for TD features intentionally excluded from the OSS CLI.""" + + +class UntrustedHydrationSourceError(HydrationError): + """Raised when hydration would execute an untrusted local source.""" + + +@dataclass(frozen=True) +class HydratedPipeline: + """Result returned by :meth:`PipelineHydrator.hydrate_file`.""" + + data: dict[str, Any] + content: str + resolved_count: int + + +@dataclass(frozen=True) +class ResolverContext: + """Structured context passed to component resolvers and URI hooks. + + The legacy resolver signature ``(hydrator, value, path, base_dir)`` remains + supported. New downstream resolvers can accept a fifth ``context`` argument + to avoid reaching into hydrator internals for source/base/output/trust state. + """ + + kind: str + value: Any + path: str + base_dir: Path | None + base_dirs: tuple[Path, ...] + source_path: Path | None = None + output_folder: Path | None = None + verbose: bool = False + trusted_python_sources: tuple[str, ...] = () + allow_all_hydration: bool = False + error_policy: str = "warn" + resolution_overrides: Mapping[str, Any] | None = None + + +ComponentResolver = Callable[..., tuple[str, dict[str, Any]] | None] +UriReader = Callable[["PipelineHydrator", str, ResolverContext], str | None] +UriWriter = Callable[["PipelineHydrator", str, str, ResolverContext], None] + +COMPONENT_RESOLVERS: dict[str, ComponentResolver] = {} +URI_READERS: dict[str, UriReader] = {} +URI_WRITERS: dict[str, UriWriter] = {} + + +def regenerate_yaml(**kwargs: Any) -> bool: + """Generate a local Python component YAML. + + Kept as a module-level seam for callers/tests that patch this operation. + """ + + logger = kwargs.pop("logger", None) + verbose = bool(kwargs.pop("verbose", False)) + return ComponentGenerator(logger=logger, verbose=verbose).regenerate_yaml(**kwargs) + + +def register_component_resolver(kind: str, resolver: ComponentResolver) -> None: + """Register or replace a component resolver. + + ``kind`` is a reference kind or URI scheme such as ``file``, ``resolve``, + ``http``, ``https``, ``name``, ``digest``, ``local``, or + ``local_from_python``. Downstream packages can monkey-patch this registry; + for example, tangle-deploy can add ``local_from_docker`` without forking + the hydrator. + """ + + COMPONENT_RESOLVERS[kind] = resolver + + +def available_component_resolvers() -> list[str]: + """Return registered resolver kinds in stable display order.""" + + return sorted(COMPONENT_RESOLVERS) + + +def register_uri_reader(scheme: str, reader: UriReader) -> None: + """Register a native-free URI reader hook for schemes such as ``gs``. + + OSS provides the dispatch seam only; downstream packages own credentials and + scheme-specific SDK dependencies. + """ + + URI_READERS[scheme] = reader + + +def register_uri_writer(scheme: str, writer: UriWriter) -> None: + """Register a native-free URI writer hook for schemes such as ``gs``.""" + + URI_WRITERS[scheme] = writer + + +def available_uri_readers() -> list[str]: + """Return registered URI reader schemes in stable display order.""" + + return sorted(URI_READERS) + + +def available_uri_writers() -> list[str]: + """Return registered URI writer schemes in stable display order.""" + + return sorted(URI_WRITERS) + + +def _available_resolvers_text(resolvers: Mapping[str, Any]) -> str: + return ", ".join(sorted(resolvers)) or "(none)" + + +def render_template( + template_path: Path, + context: dict[str, Any], + overrides: dict[str, Any] | None = None, +) -> str: + """Render a Jinja2 template with the given context. + + Ported from TD's ``render_template`` helper, including ``include_raw``. + """ + + from jinja2 import Environment, FileSystemLoader + + template_dir = template_path.parent + template_name = template_path.name + env = Environment( + loader=FileSystemLoader(str(template_dir)), + keep_trailing_newline=True, + ) + + def include_raw(path: str) -> str: + """Include a file's contents without Jinja2 processing.""" + assert env.loader is not None + return env.loader.get_source(env, path)[0] + + env.globals["include_raw"] = include_raw + template = env.get_template(template_name) + + merged_context = dict(context) + if overrides: + merged_context.update(overrides) + + return template.render(**merged_context) + + +class PipelineHydrator(TangleCliHandler): + """Hydrates pipeline YAML by resolving component references. + + This class mirrors TD's ``PipelineHydrator`` shape. Supported generic refs: + ``digest``, ``name``, ``url`` (``file://``, ``http(s)://``, ``resolve://``), + resolve-config ``local`` and ``local_from_python``. Unsupported TD refs: + GCS and Docker/from-container materialization. + """ + + def __init__( + self, + client: TangleApiClient | None = None, + upgrade_deprecated: bool = True, + verbose: bool = False, + enable_resolution: bool = True, + postprocess_task: Callable[[str, dict[str, Any], str], dict[str, Any]] | None = None, + logger: Logger | None = None, + resolution_overrides: dict[str, Any] | None = None, + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, + include_env_credentials: bool = True, + component_resolvers: Mapping[str, ComponentResolver] | None = None, + uri_readers: Mapping[str, UriReader] | None = None, + uri_writers: Mapping[str, UriWriter] | None = None, + component_generator: ComponentGenerator | None = None, + trusted_python_sources: list[str] | None = None, + allow_all_hydration: bool = False, + recursive_context: str | None = None, + error_policy: str = "warn", + ) -> None: + super().__init__(client=client, logger=logger, base_url=base_url) + self._client_options = { + "base_url": base_url, + "token": token, + "auth_header": auth_header, + "header": header, + "include_env_credentials": include_env_credentials, + } + self.cache: dict[str, Any] = {} + self.upgrade_deprecated = upgrade_deprecated + self.verbose = verbose + self.enable_resolution = enable_resolution + self._postprocess_callback = postprocess_task + self.component_resolvers: dict[str, ComponentResolver] = dict(COMPONENT_RESOLVERS) + if component_resolvers: + self.component_resolvers.update(component_resolvers) + self.uri_readers: dict[str, UriReader] = dict(URI_READERS) + if uri_readers: + self.uri_readers.update(uri_readers) + self.uri_writers: dict[str, UriWriter] = dict(URI_WRITERS) + if uri_writers: + self.uri_writers.update(uri_writers) + self.component_generator = component_generator + self.resolution_overrides: dict[str, Any] = resolution_overrides or {} + self.trusted_python_sources = trusted_python_sources or [] + self.allow_all_hydration = allow_all_hydration + self.recursive_context = self._recursive_context_value(recursive_context) + self._global_params: dict[str, Any] = {} + self.error_policy = error_policy + self._resolution_overrides_str: dict[str, str] = { + k: str(v) for k, v in self.resolution_overrides.items() + } + + def _create_client(self) -> TangleApiClient | None: + from . import client as client_module + + return client_module.TangleApiClient( + timeout=DEFAULT_TIMEOUT_SECONDS, + **self._client_options, + ) + + def _api_client(self) -> TangleApiClient: + client = self._get_client() + if client is None: + raise HydrationError("Failed to create TangleApiClient") + return client + + @staticmethod + def _recursive_context_value(value: Any) -> str | None: + if value is None: + return None + raw = getattr(value, "value", value) + normalized = str(raw).replace("_", "-").lower() + if normalized in {"parent-priority", "parent"}: + return "parent-priority" + if normalized in {"child-priority", "child"}: + return "child-priority" + raise ValueError(f"Unsupported recursive_context: {value!r}") + + def _cache_key( + self, + ref_type: str, + ref_value: str, + base_dir: Path | None = None, + ) -> str: + """Compute a cache key for a component reference.""" + key = f"{ref_type}:{ref_value}" + if self._ref_depends_on_base_dir(ref_type, ref_value): + resolved_base_dir = base_dir.resolve() if base_dir is not None else None + key = f"{key}:base={resolved_base_dir}" + if self.recursive_context and self._global_params: + params_hash = hash(json.dumps(self._global_params, sort_keys=True, default=str)) + return f"{key}:ctx={params_hash}" + return key + + def _ref_depends_on_base_dir(self, ref_type: str, ref_value: str) -> bool: + """Return whether a ref resolves relative to the active base directory.""" + if ref_type in {"local", "local_from_python"}: + return True + if ref_type != "url": + return False + + url = str(ref_value) + scheme = self._uri_scheme(url) + if scheme is None: + return not Path(url).is_absolute() + if scheme == "file": + return not Path(url[7:]).is_absolute() + if scheme == "resolve": + file_path = url[len("resolve://"):] + if "#" in file_path: + file_path, _fragment = file_path.rsplit("#", 1) + nested_scheme = self._uri_scheme(file_path) + if nested_scheme is None: + return not Path(file_path).is_absolute() + if nested_scheme == "file": + return not Path(file_path[7:]).is_absolute() + return False + + def _merge_with_global_params(self, child_params: dict[str, Any]) -> dict[str, Any]: + """Merge child template params with inherited recursive-context params. + + ``parent-priority`` means inherited params win on conflicts; + ``child-priority`` means the child template config wins. + """ + + if not self.recursive_context or not self._global_params: + return dict(child_params) + if self.recursive_context == "parent-priority": + merged = dict(child_params) + merged.update(self._global_params) + return merged + merged = dict(self._global_params) + merged.update(child_params) + return merged + + def _resolver_base_dirs(self, base_dir: Path | None) -> tuple[Path, ...]: + dirs = [path.resolve() for path in (base_dir, Path.cwd()) if path is not None] + seen: set[Path] = set() + result: list[Path] = [] + for path in dirs: + if path not in seen: + seen.add(path) + result.append(path) + return tuple(result) + + def _resolve_context_path(self, value: Any, base_dir: Path | None) -> Path | None: + if not value: + return None + path = Path(str(value)) + if path.is_absolute(): + return path.resolve() + return (base_dir / path).resolve() if base_dir is not None else path.resolve() + + def make_resolver_context( + self, + kind: str, + value: Any, + path: str, + base_dir: Path | None, + ) -> ResolverContext: + """Build the structured context passed to downstream resolver hooks.""" + + source_path = None + output_folder = None + if isinstance(value, (str, Path)) and "://" not in str(value): + source_path = self._resolve_context_path(value, base_dir) + elif isinstance(value, dict): + source_path = self._resolve_context_path( + value.get("file") or value.get("source"), base_dir + ) + output_folder = self._resolve_context_path(value.get("output_folder"), base_dir) + return ResolverContext( + kind=kind, + value=value, + path=path, + base_dir=base_dir, + base_dirs=self._resolver_base_dirs(base_dir), + source_path=source_path, + output_folder=output_folder, + verbose=self.verbose, + trusted_python_sources=tuple(self.trusted_python_sources), + allow_all_hydration=self.allow_all_hydration, + error_policy=self.error_policy, + resolution_overrides=self.resolution_overrides, + ) + + @staticmethod + def _accepts_resolver_context(resolver: ComponentResolver) -> bool: + try: + params = signature(resolver).parameters.values() + except (TypeError, ValueError): + return True + positional = [ + p for p in params + if p.kind in {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD} + ] + return any(p.kind == Parameter.VAR_POSITIONAL for p in params) or len(positional) >= 5 + + def _call_component_resolver( + self, + resolver: ComponentResolver, + value: Any, + path: str, + base_dir: Path | None, + context: ResolverContext, + ) -> tuple[str, dict[str, Any]] | None: + if self._accepts_resolver_context(resolver): + return resolver(self, value, path, base_dir, context) + return resolver(self, value, path, base_dir) + + @staticmethod + def _uri_scheme(uri: str) -> str | None: + if "://" not in uri: + return None + return uri.split("://", 1)[0] + + def _warn_or_raise_hydration_error( + self, + message: str, + exc: Exception | None = None, + ) -> None: + if isinstance(exc, UntrustedHydrationSourceError): + raise exc + if self.error_policy == "raise": + if isinstance(exc, HydrationError): + raise exc + raise HydrationError(message) from exc + self.log.warn(f" ⚠️ {message}") + + def _read_uri_text( + self, + uri: str, + kind: str, + context: ResolverContext | None = None, + ) -> str | None: + scheme = self._uri_scheme(uri) + if not scheme or scheme == "file": + path = Path(uri[7:] if uri.startswith("file://") else uri) + return path.read_text(encoding="utf-8") + reader = self.uri_readers.get(scheme) + if reader is None: + raise UnsupportedHydrationFeatureError( + f"Unsupported {kind} URI scheme {scheme!r}. Registered URI readers: " + f"{_available_resolvers_text(self.uri_readers)}" + ) + hook_context = context or self.make_resolver_context(scheme, uri, kind, None) + try: + return reader(self, uri, hook_context) + except FileNotFoundError as exc: + message = f"{kind.capitalize()} not found at URI {uri}" + if kind == "pipeline": + raise HydrationError(message) from exc + self._warn_or_raise_hydration_error(message, exc) + return None + except Exception as exc: + message = f"Error reading {kind} URI {uri}: {exc}" + if kind == "pipeline": + raise HydrationError(message) from exc + self._warn_or_raise_hydration_error(message, exc) + return None + + def _write_uri_text( + self, + uri: str, + content: str, + context: ResolverContext | None = None, + ) -> None: + scheme = self._uri_scheme(uri) + if not scheme or scheme == "file": + path = Path(uri[7:] if uri.startswith("file://") else uri) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return + writer = self.uri_writers.get(scheme) + if writer is None: + raise UnsupportedHydrationFeatureError( + f"Unsupported output URI scheme {scheme!r}. Registered URI writers: " + f"{_available_resolvers_text(self.uri_writers)}" + ) + hook_context = context or self.make_resolver_context(scheme, uri, "output", None) + writer(self, uri, content, hook_context) + + def available_component_resolvers(self) -> list[str]: + """Return resolver kinds available on this hydrator instance.""" + + return sorted(self.component_resolvers) + + def register_component_resolver(self, kind: str, resolver: ComponentResolver) -> None: + """Register or replace a resolver on this hydrator instance.""" + + self.component_resolvers[kind] = resolver + + def _unsupported_resolver(self, kind: str) -> UnsupportedHydrationFeatureError: + return UnsupportedHydrationFeatureError( + f"Unsupported component resolver {kind!r}. Available resolvers: " + f"{_available_resolvers_text(self.component_resolvers)}" + ) + + def _resolve_registered_component( + self, + kind: str, + value: Any, + path: str, + base_dir: Path | None, + ) -> tuple[str, dict[str, Any]] | None: + resolver = self.component_resolvers.get(kind) + if resolver is None: + raise self._unsupported_resolver(kind) + context = self.make_resolver_context(kind, value, path, base_dir) + result = self._call_component_resolver(resolver, value, path, base_dir, context) + if result is None: + return None + digest, spec = result + return digest, self.normalize_component_spec(spec) + + def normalize_component_spec(self, component: Any) -> dict[str, Any]: + """Return a mutable YAML-shaped copy of a component spec. + + Core resolver code operates on component specs as dictionaries. API + clients and downstream packages may return generated model instances + instead; this hook is the normalization seam that lets them keep their + client-specific return types without overriding fetch/name/latest/digest + resolver logic. + """ + if isinstance(component, Mapping): + return copy.deepcopy(dict(component)) + + to_mutable_spec_dict = getattr(component, "to_mutable_spec_dict", None) + if callable(to_mutable_spec_dict): + spec = to_mutable_spec_dict() + if isinstance(spec, Mapping): + return copy.deepcopy(dict(spec)) + + data = getattr(component, "data", None) + if isinstance(data, Mapping): + return copy.deepcopy(dict(data)) + + raise HydrationError( + "Component spec must be a mapping or expose mapping-like data; " + f"got {type(component).__name__}" + ) + + def fetch_component(self, digest: str) -> tuple[str, dict[str, Any]]: + """Fetch a component, optionally following deprecation successors.""" + client = self._api_client() + current_digest = client.resolve_digest(digest) if self.upgrade_deprecated else digest + component = client.get_component_spec(current_digest) + spec = self.normalize_component_spec(component) + if self.verbose: + self.log.info(f" [verbose] get_component_spec({current_digest}):") + self.log.info(json.dumps(spec, indent=2, default=str)) + return current_digest, spec + + def _fetch_component_by_digest( + self, + digest: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]]: + """Fetch a component by digest and return as dict.""" + self.log.info(f" Fetching component: {digest[:16]}... ({path})") + resolved_digest, component = self.fetch_component(digest) + return resolved_digest, self.normalize_component_spec(component) + + def _find_latest_version_component( + self, + components: list[ComponentInfo], + ) -> tuple[str, dict[str, Any]]: + """Find the component with the highest version from a list.""" + client = self._api_client() + + def _fetch(digest: str) -> tuple[str, dict[str, Any]]: + component = client.get_component_spec(digest) + if not component: + raise HydrationError(f"Component not found: {digest}") + return digest, self.normalize_component_spec(component) + + digests = [c.digest for c in components if c.digest] + if not digests: + raise HydrationError("No components with a digest found") + if len(digests) == 1: + return _fetch(digests[0]) + + versioned_components: list[tuple[str, dict[str, Any], str]] = [] + for digest in digests: + try: + _, spec = _fetch(digest) + version = utils.get_version_from_data(spec) + if version: + versioned_components.append((digest, spec, version)) + except Exception as exc: + self.log.warn(f" ⚠️ Failed to fetch component {digest[:16]}...: {exc}") + + if not versioned_components: + return _fetch(digests[0]) + + best_digest, best_spec, best_version = versioned_components[0] + for digest, spec, version in versioned_components[1:]: + if utils.compare_versions(version, best_version) > 0: + best_digest, best_spec, best_version = digest, spec, version + return best_digest, best_spec + + def _fetch_component_by_name( + self, + component_name: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Fetch a component by name and return as dict.""" + self.log.info(f" Finding component by name: {component_name}... ({path})") + search_names = [component_name, add_official_prefix(component_name)] + existing = self._api_client().find_existing_components(search_names, verbose=False) + if not existing: + self.log.warn(f" ⚠️ No component found with name: {component_name}") + return None + found_digest, spec = self._find_latest_version_component(existing) + self.log.info(f" Found digest: {found_digest[:16]}...") + return found_digest, spec + + def _fetch_component_by_url( + self, + url: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Fetch a component by URL and return as dict.""" + scheme = self._uri_scheme(url) + if scheme == "resolve": + result = self._fetch_component_by_resolve_url(url, path, base_dir) + else: + try: + if scheme is None: + if base_dir is None and not Path(url).is_absolute(): + raise HydrationError( + f"Scheme-less component URL {url!r} requires a local base directory" + ) + result = self._fetch_component_from_file_url(f"file://{url}", path, base_dir) + elif scheme in {"http", "https"}: + # Preserve HTTP(S) component URL behavior through the + # overridable component resolver. HTTP(S) URI readers are + # registered for non-component URI contexts such as + # ``resolve://https://...`` configs. + result = self._resolve_registered_component(scheme, url, path, base_dir) + elif scheme in self.uri_readers: + result = self._fetch_component_from_uri(url, path, base_dir) + else: + result = self._resolve_registered_component(scheme, url, path, base_dir) + except HydrationError as exc: + if isinstance(exc, UntrustedHydrationSourceError): + raise + self._warn_or_raise_hydration_error(str(exc), exc) + return None + except Exception as exc: + self._warn_or_raise_hydration_error( + f"Failed to fetch component from URL {url}: {exc}", exc + ) + return None + + if self.verbose and result is not None: + _, spec = result + self.log.info(" [verbose] Component spec from URL:") + self.log.info(json.dumps(spec, indent=2, default=str)) + return result + + def fetch_remote_component( + self, + url: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Fetch a remote HTTP(S) component. + + Kept as an overridable hook for downstream packages that need custom + transport, auth, mirrors, or auditing. + """ + self.log.info(f" Downloading component from URL: {url}... ({path})") + try: + with urllib.request.urlopen(url, timeout=30) as response: + yaml_text = response.read().decode("utf-8") + spec = yaml.safe_load(yaml_text) + except urllib.error.URLError as exc: + raise HydrationError(f"Failed to download YAML from {url}: {exc}") from exc + except yaml.YAMLError as exc: + raise HydrationError(f"Failed to parse downloaded YAML from {url}: {exc}") from exc + + if not isinstance(spec, dict): + raise HydrationError(f"Component YAML at {url} must be a mapping") + digest = utils.compute_text_digest(yaml_text) + self.log.info( + f" ✅ Downloaded component: {spec.get('name', 'unknown')} " + f"(digest: {digest[:16]}...)" + ) + return digest, spec + + def _fetch_component_from_uri( + self, + url: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Fetch component YAML through a registered URI reader hook.""" + + context = self.make_resolver_context(self._uri_scheme(url) or "url", url, path, base_dir) + yaml_text = self._read_uri_text(url, "component", context) + if yaml_text is None: + return None + try: + spec = yaml.safe_load(yaml_text) + except yaml.YAMLError as exc: + self._warn_or_raise_hydration_error( + f"Failed to parse component YAML from {url}: {exc}", exc + ) + return None + if spec is None: + self._warn_or_raise_hydration_error(f"Failed to parse YAML from {url}") + return None + if not isinstance(spec, dict): + self._warn_or_raise_hydration_error( + f"Component YAML at {url} is a {type(spec).__name__}, expected a mapping" + ) + return None + if "template_file" in spec: + self._warn_or_raise_hydration_error( + "Component at non-local URI is a template_file config; " + "render it locally and publish the rendered result instead" + ) + return None + digest = utils.compute_text_digest(yaml_text) + self.log.info( + f" ✅ Loaded component: {spec.get('name', 'unknown')} " + f"(digest: {digest[:16]}...)" + ) + return digest, spec + + def load_gcs_uri( + self, + url: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Compatibility hook for downstream GCS support via URI readers.""" + + return self._fetch_component_from_uri(url, path, base_dir) + + def _render_template_config( + self, + file_path: Path, + config: dict[str, Any], + overrides: dict[str, Any] | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """If config contains template_file, render the Jinja2 template.""" + if "template_file" not in config: + return None + + template_path = config["template_file"] + full_template_path = (file_path.parent / template_path).resolve() + if not full_template_path.exists(): + self.log.warn(f" ⚠️ Template file not found: {full_template_path}") + return None + + context = {k: v for k, v in config.items() if k != "template_file"} + if overrides: + context.update(overrides) + self.log.info(f" 🔧 Rendering template: {template_path}") + rendered = render_template(full_template_path, context) + spec = yaml.safe_load(rendered) + if not isinstance(spec, dict): + self.log.warn(f" ⚠️ Rendered template produced invalid YAML: {full_template_path}") + return None + return rendered, spec + + def postprocess_loaded_local_spec( + self, + spec: dict[str, Any], + *, + file_path: Path, + yaml_text: str, + rendered_from_template: bool, + ) -> dict[str, Any]: + """Hook for downstream metadata on locally loaded component specs. + + Called after local file/template loading and ``_source_dir`` provenance + are applied, before digest calculation and nested ref resolution. The + default implementation is native-free and leaves the spec unchanged. + """ + del file_path, yaml_text, rendered_from_template + return spec + + def _fetch_component_from_file_url( + self, + url: str, + display_path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Fetch a component from a file:// URL. + + Supports absolute and relative ``file://`` URLs and template configs, + matching TD's generic local-file behavior. + """ + file_path = url[7:] + self.log.info(f" Loading component from file URL: {url}... ({display_path})") + path_obj = Path(file_path) + if not path_obj.is_absolute() and base_dir: + path_obj = (base_dir / path_obj).resolve() + else: + path_obj = path_obj.resolve() + if not path_obj.exists(): + self.log.warn(f" ⚠️ Component file not found: {path_obj}") + return None + + try: + yaml_text = path_obj.read_text(encoding="utf-8") + spec = yaml.safe_load(yaml_text) + except Exception as exc: + raise HydrationError(f"Error reading component file {path_obj}: {exc}") from exc + if not isinstance(spec, dict): + raise HydrationError(f"Component file {path_obj} must contain a mapping") + + rendered_from_template = False + if "template_file" in spec: + merged_params: dict[str, Any] | None = None + if self.recursive_context and self._global_params: + child_params = {k: v for k, v in spec.items() if k != "template_file"} + merged_params = self._merge_with_global_params(child_params) + spec = {"template_file": spec["template_file"], **merged_params} + result = self._render_template_config(path_obj, spec) + if result is None: + return None + yaml_text, spec = result + rendered_from_template = True + if merged_params is not None: + spec["_recursive_params"] = merged_params + + # Match TD provenance behavior: nested refs inside a loaded component + # resolve relative to the component file that contains them, not the + # original top-level pipeline file. + spec["_source_dir"] = str(path_obj.parent) + try: + spec = self.postprocess_loaded_local_spec( + spec, + file_path=path_obj, + yaml_text=yaml_text, + rendered_from_template=rendered_from_template, + ) + except Exception as exc: + raise HydrationError( + f"Error postprocessing component file {path_obj}: {exc}" + ) from exc + + digest = utils.compute_text_digest(yaml_text) + self.log.info( + f" ✅ Loaded component: {spec.get('name', 'unknown')} " + f"(digest: {digest[:16]}...)" + ) + return digest, spec + + def _fetch_component_by_resolve_url( + self, + url: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Fetch a component using a resolve:// URL pointing to a config.""" + file_path = url[len("resolve://"):] + fragment: str | None = None + if "#" in file_path: + file_path, fragment = file_path.rsplit("#", 1) + + self.log.info(f" Resolving component via config: {url}... ({path})") + + scheme = self._uri_scheme(file_path) + source: str + if scheme and scheme != "file": + source = file_path + nested_base_dir = None + text = self._read_uri_text( + file_path, + "resolve config", + self.make_resolver_context(scheme, file_path, path, base_dir), + ) + if text is None: + return None + else: + raw_path = file_path[7:] if file_path.startswith("file://") else file_path + path_obj = Path(raw_path) + if not path_obj.is_absolute() and base_dir: + path_obj = (base_dir / path_obj).resolve() + else: + path_obj = path_obj.resolve() + if not path_obj.exists(): + self.log.warn(f" ⚠️ Resolve config not found: {path_obj}") + return None + source = str(path_obj) + nested_base_dir = path_obj.parent + try: + text = path_obj.read_text(encoding="utf-8") + except Exception as exc: + self._warn_or_raise_hydration_error( + f"Error reading resolve config {path_obj}: {exc}", exc + ) + return None + + try: + text = utils.expand_vars(text, self._resolution_overrides_str) + config = yaml.safe_load(text) + except utils.UnsetVarError as exc: + self.log.warn(f" ⚠️ Resolve config {source}: unset variable {exc}") + return None + except Exception as exc: + self._warn_or_raise_hydration_error( + f"Error parsing resolve config {source}: {exc}", exc + ) + return None + + if fragment is not None: + if not isinstance(config, dict) or fragment not in config: + self.log.warn( + f" ⚠️ Fragment '{fragment}' not found in resolve config {source}" + ) + return None + entry = config[fragment] + defaults = config.get("_defaults") + if isinstance(defaults, dict) and isinstance(entry, (dict, list)): + config = utils.apply_defaults(entry, defaults) + else: + config = entry + + return self._resolve_from_config(config, path, nested_base_dir) + + def _resolve_from_config( + self, + config: dict[str, Any] | list[dict[str, Any]], + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Resolve a component from a parsed resolve config.""" + entries = config if isinstance(config, list) else [config] + for i, entry in enumerate(entries): + if not isinstance(entry, dict): + self.log.warn(f" ⚠️ Resolve config entry {i} is not a dict, skipping") + continue + result = self._try_resolve_entry(entry, path, base_dir) + if result is not None: + return result + self.log.warn(f" ⚠️ No resolve config entry matched at {path}") + return None + + def _try_resolve_entry( + self, + entry: dict[str, Any], + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Try to resolve a single resolve-config entry. + + Generic resolution lives here: resolve the primary source, choose one + local-side resolver by registry order, optionally use a cheap version + preview to skip materialization, then compare versions when both sides + resolve. Downstreams add new local-side behavior by registering a + resolver and (optionally) overriding ``preview_resolver_version``. + """ + primary = self._resolve_primary(entry, path, base_dir) + local_kind, local_value = self._select_local_entry(entry) + + if primary and local_kind and local_value: + preview_winner = self._preview_decide_winner( + local_kind, local_value, primary, base_dir + ) + if preview_winner == "primary": + return primary + else: + preview_winner = None + + local_result = None + if local_kind and local_value: + local_result = self._resolve_registered_component( + local_kind, local_value, path, base_dir + ) + + if not primary and not local_result: + return None + if not primary: + self.log.info( + f" Resolve: primary source failed, using {local_kind or 'local source'}" + ) + return local_result + if not local_result: + return primary + if preview_winner == "local": + return local_result + return self._pick_higher_version(primary, local_result, path) + + def _resolve_primary( + self, + entry: dict[str, Any], + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Resolve the primary source from a resolve-config entry.""" + for kind in ("digest", "url"): + if kind not in entry: + continue + value = entry[kind] + if kind == "digest": + self.log.info(f" Resolve: trying digest={str(value)[:16]}...") + elif kind == "url": + self.log.info(f" Resolve: trying url={value}") + return self._resolve_registered_component(kind, value, path, base_dir) + if "name" in entry: + return self._resolve_by_name_with_filters(entry) + if any(kind in entry for kind in self._resolve_entry_kinds()): + return None + self.log.warn( + " ⚠️ Resolve config entry has no registered resolver key. " + f"Available resolvers: {_available_resolvers_text(self.component_resolvers)}" + ) + return None + + def _select_local_entry(self, entry: dict[str, Any]) -> tuple[str | None, Any]: + """Return the local-side resolver selected for a resolve-config entry. + + Registry order defines priority. If multiple local resolver fields are + present, use the first and warn about the ignored ones. This keeps + precedence generic so downstream-only fields (for example TD's + ``local_from_docker``) don't require overriding the whole resolve flow. + """ + + used = [ + (kind, entry[kind]) + for kind in self._resolve_entry_kinds() + if kind in entry and kind not in {"digest", "url", "name"} and entry[kind] + ] + if not used: + return None, None + if len(used) > 1: + kept, ignored = used[0], [kind for kind, _ in used[1:]] + self.log.warn( + f" ⚠️ Resolve entry has multiple local sources " + f"{[kind for kind, _ in used]}; using '{kept[0]}' and ignoring {ignored}" + ) + return used[0] + + def _resolve_local_side( + self, + entry: dict[str, Any], + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + kind, value = self._select_local_entry(entry) + if kind and value: + return self._resolve_registered_component(kind, value, path, base_dir) + return None + + def _resolve_entry_kinds(self) -> tuple[str, ...]: + builtin_kinds = ( + "digest", + "url", + "name", + "local", + "local_from_python", + "local_from_docker", + "local_from_container", + "from_docker", + "from_container", + "from-docker", + "from-container", + ) + return tuple(dict.fromkeys((*builtin_kinds, *self.component_resolvers))) + + def preview_resolver_version( + self, + kind: str, + value: Any, + base_dir: Path | None = None, + ) -> str | None: + """Cheaply read a local resolver's version without materializing it. + + The base OSS implementation supports ``local_from_python`` by reading + docstring metadata via AST. Downstreams can override this for their own + local resolvers while retaining the generic resolve/compare flow. + """ + + if kind != "local_from_python" or not isinstance(value, dict): + return None + file_field = value.get("file") + if not file_field: + return None + py_path = Path(file_field) + if not py_path.is_absolute() and base_dir is not None: + py_path = (base_dir / py_path).resolve() + else: + py_path = py_path.resolve() + if not py_path.exists(): + return None + try: + from .component_from_func import extract_file_metadata + + metadata, resolved_func = extract_file_metadata(py_path, value.get("function")) + except Exception: + return None + if not resolved_func: + return None + version = metadata.get("version") + return str(version) if version else None + + def _preview_decide_winner( + self, + kind: str, + value: Any, + primary: tuple[str, dict[str, Any]], + base_dir: Path | None, + ) -> str | None: + """Use preview metadata to decide primary-vs-local before materializing.""" + + preview_version = self.preview_resolver_version(kind, value, base_dir) + if not preview_version: + return None + primary_digest, primary_spec = primary + primary_version = utils.get_version_from_data(primary_spec) + if not primary_version: + return None + primary_name = primary_spec.get("name", primary_digest[:16]) + if utils.compare_versions(preview_version, primary_version) > 0: + self.log.info( + f" Resolve: {kind} v{preview_version} > published " + f"{primary_name} v{primary_version} → using local " + f"(skipped final comparison)" + ) + return "local" + self.log.info( + f" Resolve: published {primary_name} v{primary_version} >= " + f"{kind} v{preview_version} → using published (skipped generation)" + ) + return "primary" + + def _resolve_by_name_with_filters( + self, + entry: dict[str, Any], + ) -> tuple[str, dict[str, Any]] | None: + """Resolve a component by name with optional filters.""" + component_name = entry["name"] + publisher = entry.get("publisher") + version_constraint = entry.get("version") + required_annotations = entry.get("annotations") + + search_names = [component_name, add_official_prefix(component_name)] + candidates = self._api_client().find_existing_components( + search_names, + verbose=False, + published_by=publisher, + ) + if not candidates: + self.log.info(f" Resolve: no candidates for name={component_name}") + return None + + if version_constraint: + if not _parse_version_constraint(version_constraint): + raise HydrationError(f"Invalid version constraint: '{version_constraint}'") + candidates = _filter_by_version_constraint(candidates, version_constraint) + if not candidates: + self.log.info( + f" Resolve: no candidates matching version {version_constraint}" + ) + return None + + if required_annotations: + candidates = self._filter_by_annotations(candidates, required_annotations) + if not candidates: + self.log.info( + f" Resolve: no candidates matching annotations {required_annotations}" + ) + return None + + found_digest, spec = self._find_latest_version_component(candidates) + self.log.info( + f" Resolve: matched {spec.get('name', 'unknown')} " + f"(digest: {found_digest[:16]}...)" + ) + return found_digest, spec + + def _filter_by_annotations( + self, + candidates: list[ComponentInfo], + required_annotations: dict[str, Any], + ) -> list[ComponentInfo]: + result: list[ComponentInfo] = [] + for candidate in candidates: + if not candidate.digest: + continue + try: + spec_obj = self._api_client().get_component_spec(candidate.digest) + except Exception as exc: + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) == 404: + continue + raise + attr_annotations = getattr(spec_obj, "annotations", None) + if attr_annotations: + annotations = attr_annotations + else: + try: + spec_data = self.normalize_component_spec(spec_obj) + except HydrationError as exc: + self.log.warn( + f" ⚠️ Resolve: skipping component {candidate.digest[:16]}... " + f"while reading annotations: {exc}" + ) + continue + metadata = spec_data.get("metadata", {}) + annotations = ( + metadata.get("annotations", {}) + if isinstance(metadata, Mapping) + else {} + ) or {} + if _annotations_match(annotations, required_annotations): + result.append(candidate) + return result + + def _resolve_local_file( + self, + local_path: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Resolve a local file path to a component spec.""" + raw_path = local_path[7:] if local_path.startswith("file://") else local_path + path_obj = Path(raw_path) + if not path_obj.is_absolute() and base_dir is not None: + path_obj = (base_dir / path_obj).resolve() + else: + path_obj = path_obj.resolve() + if not path_obj.exists(): + if self.verbose or utils.tangle_verbose_enabled(): + self.log.warn(f" ⚠️ Resolve: local file not found: {path_obj}") + return None + file_url = local_path if local_path.startswith("file://") else f"file://{local_path}" + self.log.info(f" Resolve: loading local file {local_path}") + return self._fetch_component_by_url(file_url, path, base_dir) + + def _resolve_local_from_python( + self, + gen_config: Any, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Generate a component YAML from a Python source file and resolve it.""" + if not isinstance(gen_config, dict): + self.log.warn(" ⚠️ 'local_from_python' must be a dict") + return None + file_field = gen_config.get("file") + if not file_field: + self.log.warn(" ⚠️ 'local_from_python' requires a 'file' field") + return None + + def _resolve_path(p: str | Path | None) -> Path | None: + if not p: + return None + pp = Path(p) + if pp.is_absolute(): + return pp + return (base_dir / pp).resolve() if base_dir is not None else pp.resolve() + + python_file = _resolve_path(file_field) + if python_file is None or not python_file.exists(): + self.log.warn(f" ⚠️ local_from_python file not found: {python_file}") + return None + python_file = python_file.resolve() + resolve_root = _resolve_path(gen_config.get("resolve_root")) + trust_base_dirs = [base_dir, Path.cwd()] + if not is_trusted_python_source( + python_file, + base_dirs=trust_base_dirs, + trusted_sources=self.trusted_python_sources, + allow_all=self.allow_all_hydration, + ): + raise UntrustedHydrationSourceError(trusted_python_source_guidance(python_file)) + + output_folder = _resolve_path(gen_config.get("output_folder")) + if output_folder is None: + if base_dir is None: + self.log.warn(" ⚠️ local_from_python requires output_folder") + return None + output_folder = (base_dir / "generated").resolve() + output_folder.mkdir(parents=True, exist_ok=True) + + out_path = output_folder / (python_file.stem.replace("_", "-") + ".yaml") + generation_kwargs = { + "python_file": python_file, + "output_path": out_path, + "function_name": gen_config.get("function"), + "custom_name": gen_config.get("name"), + "image": gen_config.get("image"), + "dependencies_from": _resolve_path(gen_config.get("dependencies_from")), + "strip_code": bool(gen_config.get("strip_code", False)), + "mode": str(gen_config.get("mode", "inline")), + "resolve_root": resolve_root, + } + if self.component_generator is not None: + success = self.component_generator.regenerate_yaml(**generation_kwargs) + else: + success = regenerate_yaml(**generation_kwargs, logger=self.log) + if not success or not out_path.exists(): + self.log.warn(f" ⚠️ local_from_python failed to generate {out_path}") + return None + return self._resolve_local_file(str(out_path), path, base_dir) + + def _pick_higher_version( + self, + primary: tuple[str, dict[str, Any]], + local: tuple[str, dict[str, Any]], + path: str, + ) -> tuple[str, dict[str, Any]]: + primary_version = utils.get_version_from_data(primary[1]) + local_version = utils.get_version_from_data(local[1]) + if local_version and primary_version: + if utils.compare_versions(local_version, primary_version) > 0: + return local + return primary + if local_version and not primary_version: + return local + return primary + + def _resolve_task( + self, + task_name: str, + task_data: dict[str, Any], + path: str, + base_dir: Path | None = None, + recursive_params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Resolve component references to full componentRef with spec.""" + if recursive_params is not None: + self._global_params = recursive_params + else: + self._global_params = {} + if not isinstance(task_data, dict): + return task_data + + legacy_mappings = [ + ("componentUrl", "url"), + ("componentName", "name"), + ("componentDigest", "digest"), + ] + for legacy_key, ref_type in legacy_mappings: + if legacy_key in task_data: + ref_value = task_data[legacy_key] + if ref_value and self.enable_resolution: + return self._resolve_component_ref( + task_name, + task_data, + path, + ref_type, + ref_value, + remove_key=legacy_key, + base_dir=base_dir, + ) + return task_data + + if "componentRef" not in task_data: + return task_data + component_ref = task_data["componentRef"] + if not isinstance(component_ref, dict) or "spec" in component_ref: + return task_data + if not self.enable_resolution: + return task_data + + present_refs = [ + (key, component_ref[key]) + for key in ("digest", "name", "url") + if key in component_ref and component_ref[key] + ] + if not present_refs: + return task_data + if len(present_refs) == 1: + ref_type, ref_value = present_refs[0] + return self._resolve_component_ref( + task_name, + task_data, + path, + ref_type, + ref_value, + remove_key="componentRef", + base_dir=base_dir, + ) + return self._resolve_best_ref(task_name, task_data, path, present_refs, base_dir) + + def _resolve_component_ref( + self, + task_name: str, + task_data: dict[str, Any], + path: str, + ref_type: str, + ref_value: str, + remove_key: str, + base_dir: Path | None = None, + ) -> dict[str, Any]: + """Resolve a component reference to full componentRef with spec.""" + cache_key = self._cache_key(ref_type, ref_value, base_dir) + if cache_key not in self.cache: + result = self._resolve_registered_component(ref_type, ref_value, path, base_dir) + if result is None: + if not self._postprocess_callback: + raise HydrationError(f"Component not found: {ref_type}={ref_value} at {path}") + processed = self._postprocess_callback(task_name, task_data, path) + component_ref = processed.get("componentRef") + if not component_ref: + raise HydrationError(f"Component not found: {ref_type}={ref_value} at {path}") + else: + digest, spec = result + component_ref = { + "name": spec.get("name", ""), + "digest": digest, + "spec": spec, + } + if self._postprocess_callback: + new_task = {k: v for k, v in task_data.items() if k != remove_key} + new_task["componentRef"] = component_ref + processed = self._postprocess_callback(task_name, new_task, path) + component_ref = processed.get("componentRef", component_ref) + self.cache[cache_key] = component_ref + + new_task = {k: v for k, v in task_data.items() if k != remove_key} + new_task["componentRef"] = copy.deepcopy(self.cache[cache_key]) + return new_task + + def _try_resolve_single_ref( + self, + ref_type: str, + ref_value: str, + path: str, + base_dir: Path | None, + ) -> tuple[str, str, str | None, dict[str, Any]] | None: + """Resolve one ref and return metadata for best-ref selection.""" + cache_key = self._cache_key(ref_type, ref_value, base_dir) + try: + if cache_key not in self.cache: + result = self._resolve_registered_component(ref_type, ref_value, path, base_dir) + if result is None: + self.log.warn(f" ⚠️ Could not resolve {ref_type}={ref_value}") + return None + digest, spec = result + self.cache[cache_key] = { + "name": spec.get("name", ""), + "digest": digest, + "spec": spec, + } + component_ref = self.cache[cache_key] + version = utils.get_version_from_data(component_ref.get("spec", {})) + return (ref_type, ref_value, version, component_ref) + except Exception as exc: + self.log.warn(f" ⚠️ Failed to resolve {ref_type}={ref_value}: {exc}") + return None + + @staticmethod + def _pick_best_candidate( + candidates: list[tuple[str, str, str | None, dict[str, Any]]], + ) -> tuple[str, str, str | None, dict[str, Any]]: + """Pick candidate with highest version; tie-break digest > name > url.""" + priority = {"digest": 0, "name": 1, "url": 2} + + def _is_better(candidate, current): + c_type, _, c_ver, _ = candidate + b_type, _, b_ver, _ = current + if c_ver and b_ver: + cmp = utils.compare_versions(c_ver, b_ver) + if cmp != 0: + return cmp > 0 + elif c_ver and not b_ver: + return True + elif not c_ver and b_ver: + return False + return priority.get(c_type, 99) < priority.get(b_type, 99) + + best = candidates[0] + for candidate in candidates[1:]: + if _is_better(candidate, best): + best = candidate + return best + + def _resolve_best_ref( + self, + task_name: str, + task_data: dict[str, Any], + path: str, + refs: list[tuple[str, str]], + base_dir: Path | None = None, + ) -> dict[str, Any]: + """Resolve multiple refs and pick the highest version.""" + candidates = [] + for ref_type, ref_value in refs: + result = self._try_resolve_single_ref(ref_type, ref_value, path, base_dir) + if result: + candidates.append(result) + if not candidates: + ref_type, ref_value = refs[0] + return self._resolve_component_ref( + task_name, + task_data, + path, + ref_type, + ref_value, + remove_key="componentRef", + base_dir=base_dir, + ) + _chosen_type, _chosen_value, _chosen_version, chosen_ref = self._pick_best_candidate( + candidates + ) + new_task = {k: v for k, v in task_data.items() if k != "componentRef"} + new_task["componentRef"] = copy.deepcopy(chosen_ref) + return new_task + + def resolve_components( + self, + data: dict[str, Any], + base_dir: Path | None = None, + ) -> dict[str, Any]: + """Traverse pipeline YAML and resolve componentRef references.""" + + def process_task( + task_name: str, + task_data: dict[str, Any], + path: str, + task_base_dir: Path | None = None, + recursive_params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._resolve_task( + task_name, task_data, path, task_base_dir, recursive_params + ) + + pipeline_name = data.get("name", "pipeline") + initial_params = self._global_params.copy() if self._global_params else None + return utils.traverse_pipeline_tasks( + data, pipeline_name, process_task, base_dir, initial_params + ) + + @property + def resolved_count(self) -> int: + """Return the number of resolved components.""" + return len(self.cache) + + def hydrate_file( + self, + input_file: Path | str, + output_file: Path | str | None = None, + overrides: dict[str, str] | None = None, + ) -> HydratedPipeline: + """Hydrate a pipeline YAML file.""" + self._global_params = {} + try: + input_str = str(input_file) + input_scheme = self._uri_scheme(input_str) + input_path: Path | None = None + base_dir: Path | None = None + try: + if input_scheme and input_scheme != "file": + yaml_text = self._read_uri_text( + input_str, + "pipeline", + self.make_resolver_context(input_scheme, input_str, "pipeline", None), + ) + config = yaml.safe_load(yaml_text) if yaml_text is not None else None + else: + raw_input = input_str[7:] if input_str.startswith("file://") else input_str + input_path = Path(raw_input) + base_dir = input_path.parent.resolve() + config = yaml.safe_load(input_path.read_text(encoding="utf-8")) + except Exception as exc: + raise HydrationError(f"Failed to read pipeline YAML {input_file}: {exc}") from exc + if config is None: + config = {} + if not isinstance(config, dict): + raise HydrationError("Pipeline YAML must contain a top-level mapping") + + if "template_file" in config: + if input_path is None: + raise UnsupportedHydrationFeatureError( + "template_file configs require a local pipeline input" + ) + result = self._render_template_config(input_path, config, overrides=overrides) + if result is None: + raise HydrationError( + f"Template file not found: {(base_dir / config['template_file']).resolve()}" + ) + _, output_yaml = result + if self.recursive_context: + self._global_params = {k: v for k, v in config.items() if k != "template_file"} + if overrides: + self._global_params.update(overrides) + self.log.info(f"✅ Hydrated {input_file}") + else: + output_yaml = config + self.log.info(f"✅ Copied {input_file}") + + output_yaml = self.resolve_components(output_yaml, base_dir=base_dir) + output_content = utils.dump_yaml(output_yaml) + if output_file is not None: + output_str = str(output_file) + output_scheme = self._uri_scheme(output_str) + if output_scheme and output_scheme != "file": + self._write_uri_text( + output_str, + output_content, + self.make_resolver_context(output_scheme, output_str, "output", base_dir), + ) + else: + raw_output = output_str[7:] if output_str.startswith("file://") else output_str + output_path = Path(raw_output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output_content, encoding="utf-8") + return HydratedPipeline(output_yaml, output_content, self.resolved_count) + finally: + self._global_params = {} + + +# ============================================================================= +# Resolve config helpers (ported from TD) +# ============================================================================= + + +def _annotations_match(annotations: Mapping[str, Any], required: dict[str, Any]) -> bool: + """Check if a component's annotations satisfy all required constraints.""" + for key, expected in required.items(): + actual = annotations.get(key) + if isinstance(expected, list): + if actual not in [str(v) for v in expected]: + return False + else: + if actual != str(expected): + return False + return True + + +def _parse_version_constraint(constraint: str) -> list[tuple[str, str]]: + """Parse a version constraint string into ``(operator, version)`` pairs.""" + parts = [p.strip() for p in constraint.split(",") if p.strip()] + result: list[tuple[str, str]] = [] + for part in parts: + match = re.match(r"^(>=|<=|!=|>|<|==)?\s*(\d[\d.]*)", part) + if match: + op = match.group(1) or "==" + version = match.group(2) + result.append((op, version)) + return result + + +def _version_satisfies(version: str, constraint: str) -> bool: + """Check if a version string satisfies a constraint.""" + parsed = _parse_version_constraint(constraint) + if not parsed: + raise HydrationError(f"Invalid version constraint: '{constraint}'") + + for op, target in parsed: + cmp = utils.compare_versions(version, target) + satisfied = ( + (op == ">=" and cmp >= 0) + or (op == ">" and cmp > 0) + or (op == "<=" and cmp <= 0) + or (op == "<" and cmp < 0) + or (op == "==" and cmp == 0) + or (op == "!=" and cmp != 0) + ) + if not satisfied: + return False + return True + + +def _filter_by_version_constraint( + candidates: list[ComponentInfo], + constraint: str, +) -> list[ComponentInfo]: + """Filter candidates whose version satisfies a constraint string.""" + return [ + c for c in candidates + if c.version and _version_satisfies(c.version, constraint) + ] + + +class DehydrateChoice: + """Constants preserved from TD for future dehydrate porting.""" + + DIGEST = "d" + NAME = "n" + URL = "u" + FILE = "f" + KEEP = "k" + AUTO = "a" + + +# ---- Resolver registry ----------------------------------------------------- + + +def _resolve_digest( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._fetch_component_by_digest(str(value), path, base_dir) + + +def _resolve_name( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._fetch_component_by_name(str(value), path, base_dir) + + +def _resolve_url( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._fetch_component_by_url(str(value), path, base_dir) + + +def _resolve_file( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._fetch_component_from_file_url(str(value), path, base_dir) + + +def _resolve_resolve( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._fetch_component_by_resolve_url(str(value), path, base_dir) + + +def _resolve_http( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator.fetch_remote_component(str(value), path, base_dir) + + +def _read_http_uri( + hydrator: PipelineHydrator, + uri: str, + context: ResolverContext, +) -> str | None: + del context + hydrator.log.info(f" Downloading URI: {uri}...") + with urllib.request.urlopen(uri, timeout=30) as response: + return response.read().decode("utf-8") + + +def _resolve_local( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._resolve_local_file(str(value), path, base_dir) + + +def _resolve_local_from_python( + hydrator: PipelineHydrator, + value: Any, + path: str, + base_dir: Path | None, +) -> tuple[str, dict[str, Any]] | None: + return hydrator._resolve_local_from_python(value, path, base_dir) + + +register_component_resolver("digest", _resolve_digest) +register_component_resolver("name", _resolve_name) +register_component_resolver("url", _resolve_url) +register_component_resolver("file", _resolve_file) +register_component_resolver("resolve", _resolve_resolve) +register_component_resolver("http", _resolve_http) +register_component_resolver("https", _resolve_http) +register_component_resolver("local", _resolve_local) +register_component_resolver("local_from_python", _resolve_local_from_python) +register_uri_reader("http", _read_http_uri) +register_uri_reader("https", _read_http_uri) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py new file mode 100644 index 0000000..2539b7a --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py @@ -0,0 +1,41 @@ +"""Pipeline-run annotation helpers.""" + +from __future__ import annotations + +from typing import Any + +from .handler import TangleCliHandler + + +class AnnotationManager(TangleCliHandler): + """Manage annotations on Tangle pipeline runs.""" + + @staticmethod + def to_plain(value: Any) -> Any: + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "model_dump"): + return value.model_dump(by_alias=True) + return value + + def list_annotations(self, run_id: str) -> dict[str, Any]: + annotations = self.to_plain(self._require_client().pipeline_runs_annotations(run_id)) or {} + if not isinstance(annotations, dict): + annotations = dict(annotations) + return { + "status": "success", + "run_id": run_id, + "count": len(annotations), + "annotations": annotations, + } + + def set_annotation(self, run_id: str, key: str, value: Any = None) -> dict[str, Any]: + self._require_client().pipeline_runs_put_annotations(run_id, key, value=value) + return {"status": "success", "run_id": run_id, "key": key, "value": value} + + def delete_annotation(self, run_id: str, key: str) -> dict[str, Any]: + self._require_client().pipeline_runs_delete_annotations(run_id, key) + return {"status": "success", "run_id": run_id, "key": key} + + +__all__ = ["AnnotationManager"] diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py new file mode 100644 index 0000000..3591b23 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py @@ -0,0 +1,203 @@ +"""Pipeline-run details and graph-state serialization helpers. + +These helpers are native-free and keep provider-specific log enrichment out of +OSS. Downstreams can call them with their authenticated API client and layer +provider-specific log output through ``PipelineRunHooks.fetch_logs`` or wrappers. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from typing import Any + +from .handler import TangleCliHandler + + +class PipelineRunDetails(TangleCliHandler): + """Resource manager for pipeline run details and graph-state output. + + Downstream packages can subclass this class or inject a lazy + ``client_factory`` to supply authenticated clients without OSS importing + provider-specific auth or SDK code. + """ + + def __init__( + self, + client: Any = None, + *, + client_factory: Any | None = None, + logger: Any | None = None, + **kwargs: Any, + ) -> None: + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) + + @staticmethod + def to_plain(value: Any) -> Any: + """Convert generated/native objects into JSON-serializable values.""" + + return _to_plain(value) + + def serialize_execution(self, execution: Any) -> dict[str, Any]: + """Serialize an execution details object into a concise dict.""" + + out: dict[str, Any] = {"id": _value(execution, "id", "")} + task_spec = _value(execution, "task_spec") + component_spec = _value(task_spec, "component_spec") + if component_spec: + out["component"] = _value(component_spec, "name", "unknown") or "unknown" + try: + from .component_inspector import ComponentInspector + + transparent, reason = ComponentInspector.transparency_check(component_spec) + out["transparent"] = transparent + out["transparency_reason"] = reason + except Exception: + pass + description = _value(component_spec, "description") + if description: + out["description"] = description + implementation = _value(component_spec, "implementation") + if implementation: + out["implementation"] = self.to_plain(implementation) + arguments = _value(task_spec, "arguments") + if arguments: + out["arguments"] = self.to_plain(arguments) + raw = _value(execution, "raw", {}) or {} + for key in ("state", "created_at", "finished_at"): + raw_value = _value(raw, key) + if raw_value: + out[key] = raw_value + input_artifacts = _value(execution, "input_artifacts") + if input_artifacts: + out["input_artifacts"] = self.to_plain(input_artifacts) + output_artifacts = _value(execution, "output_artifacts") + if output_artifacts: + out["output_artifacts"] = self.to_plain(output_artifacts) + return out + + def serialize_run_details(self, details: Any) -> dict[str, Any]: + """Convert ``RunDetails`` into a JSON-serializable dict.""" + + if isinstance(details, dict): + return self.to_plain(details) + out: dict[str, Any] = {} + run = details.run + out["run"] = { + "id": _value(run, "id"), + "root_execution_id": _value(run, "root_execution_id"), + "created_at": _value(run, "created_at"), + "created_by": _value(run, "created_by"), + } + annotations = _value(run, "annotations") + if annotations: + out["run"]["annotations"] = self.to_plain(annotations) + if details.execution: + out["execution"] = self.serialize_execution(details.execution) + if details.annotations: + out["annotations"] = self.to_plain(details.annotations) + if details.execution_state: + out["execution_state"] = { + "totals": self.to_plain(_value(details.execution_state, "status_totals")), + "per_execution": self.to_plain(_value(details.execution_state, "child_execution_status_stats")), + } + return out + + def get_run_details_output( + self, + run_id: str, + *, + include_implementations: bool = False, + include_annotations: bool = False, + include_execution_state: bool = False, + execution_id: str | None = None, + ) -> dict[str, Any]: + """Fetch run details and return serialized output.""" + + kwargs: dict[str, Any] = { + "include_annotations": include_annotations, + "include_execution_state": include_execution_state, + } + if include_implementations: + kwargs["include_implementations"] = include_implementations + if execution_id is not None: + kwargs["execution_id"] = execution_id + details = self._require_client().get_run_details(run_id, **kwargs) + return self.serialize_run_details(details) + + def fetch_graph_state_one(self, run_id: str) -> dict[str, Any]: + """Fetch graph state for a pipeline run id or root execution id.""" + + try: + run = self._require_client().pipeline_runs_get(run_id) + except Exception as exc: + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) != 404: + raise + run = None + root_execution_id = _value(run, "root_execution_id") if run else None + root_execution_id = root_execution_id or run_id + state = self._require_client().executions_graph_execution_state(root_execution_id) + return { + "run_id": run_id, + "root_execution_id": root_execution_id, + "status_totals": self.to_plain(_value(state, "status_totals")), + "failed_execution_ids": self.to_plain(_value(state, "failed_execution_ids")), + "per_execution": self.to_plain(_value(state, "per_execution")), + "error": None, + } + + def get_graph_state_output( + self, + run_ids: list[str], + *, + timeout: float = 30.0, + ) -> dict[str, Any]: + """Fetch lightweight graph state for one or more run/execution IDs.""" + + results: list[dict[str, Any]] = [] + for run_id in run_ids: + executor = ThreadPoolExecutor(max_workers=1) + try: + future = executor.submit(self.fetch_graph_state_one, run_id) + try: + results.append(future.result(timeout=timeout)) + except FutureTimeoutError: + results.append(_error_result(run_id, f"timeout after {timeout}s")) + except Exception as exc: + results.append(_error_result(run_id, str(exc))) + finally: + executor.shutdown(wait=False) + return {"results": results} + + +def _value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) + + +def _to_plain(value: Any) -> Any: + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "model_dump"): + return value.model_dump(by_alias=True) + if isinstance(value, dict): + return {key: _to_plain(item) for key, item in value.items()} + if isinstance(value, list): + return [_to_plain(item) for item in value] + return value + + +def _error_result(run_id: str, message: str) -> dict[str, Any]: + return { + "run_id": run_id, + "root_execution_id": None, + "status_totals": None, + "failed_execution_ids": None, + "per_execution": None, + "error": message, + } + + +__all__ = ["PipelineRunDetails"] diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py new file mode 100644 index 0000000..4048882 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -0,0 +1,1994 @@ +"""Generic pipeline-run helpers for `tangle sdk pipeline-runs`. + +This module ports the OSS-safe parts of tangle-deploy's runner/run details +commands while keeping downstream-specific behavior behind hooks. The default +implementation uses only the public Tangle API and local files; cloud storage, +notifications, scheduler, mutex, run-as annotation defaults, and alternate log +backends are intentionally extension points rather than OSS behavior. +""" + +from __future__ import annotations + +import copy +import inspect +import json +import re +import time +import uuid +from collections.abc import Callable +from contextlib import AbstractContextManager, nullcontext +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from .handler import TangleCliHandler +from .logger import Logger, get_default_logger +from .pipeline_dehydrator import DehydrateChoice, PipelineDehydrator +from .pipeline_hydrator import HydrationError, PipelineHydrator +from .pipeline_run_details import PipelineRunDetails +from .pipeline_run_search import PipelineRunSearch +from .utils import dump_yaml + +_TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED", "INVALID") +_ACTIVE_STATUSES = ("RUNNING", "CANCELLING", "CANCELING", "PENDING", "QUEUED") +_FAILURE_EARLY_EXIT_STATUSES = ("FAILED", "SYSTEM_ERROR") +_EXECUTION_STATE_TIMINGS_METADATA_KEY = "execution_state_timings" +_EXECUTION_STATE_TIMING_MONOTONIC_METADATA_KEY = "_execution_state_timing_monotonic" +_SUBMISSION_ID_ANNOTATION_KEY = "tangle-cli/submission-id" +_SUBMIT_RECOVERY_LOOKUP_ATTEMPTS = 2 +_SUBMIT_RECOVERY_LOOKUP_DELAY_SECONDS = 0.1 + + +class PipelineRunError(RuntimeError): + """Raised when a pipeline-run operation cannot complete.""" + + +class UnsupportedPipelineRunFeatureError(PipelineRunError): + """Raised for TD extension points intentionally unsupported in OSS defaults.""" + + +class AmbiguousPipelineRunRecoveryError(PipelineRunError): + """Raised when submit recovery finds multiple runs for one submission id.""" + + +@dataclass +class PipelineSubmitPayload: + """Prepared submit payload state before calling ``pipeline_runs_create``. + + This keeps the generic submit-body pipeline explicit: downstream hooks can + adjust the spec, runtime arguments, run name, and annotations while callers + still have one canonical body shape to submit. + """ + + prepared_spec: dict[str, Any] + pipeline_spec: dict[str, Any] + run_args: dict[str, Any] | None + root_task: dict[str, Any] + annotations: dict[str, str] + run_name: str | None = None + + def to_body(self) -> dict[str, Any]: + return {"root_task": self.root_task, "annotations": self.annotations} + + def sync_from_body(self, body: Mapping[str, Any]) -> None: + """Refresh derived payload fields after in-place body normalization.""" + + root_task = body.get("root_task") + if isinstance(root_task, dict): + self.root_task = root_task + annotations = body.get("annotations") + if isinstance(annotations, dict): + self.annotations = {str(key): str(value) for key, value in annotations.items()} + component_ref = self.root_task.get("componentRef") if isinstance(self.root_task, Mapping) else None + submit_spec = component_ref.get("spec") if isinstance(component_ref, Mapping) else None + if isinstance(submit_spec, dict): + self.pipeline_spec = submit_spec + run_name = submit_spec.get("name") + self.run_name = run_name if isinstance(run_name, str) and run_name else None + + +@dataclass(frozen=True) +class PipelineWaitOutcome: + """Normalized wait result attached to a run context. + + This is the generic OSS result boundary for wait lifecycle decisions. + Downstreams can format legacy result dictionaries or notifications from + this typed outcome without inventing their own metadata flags for success, + timeout, failure counts, or fail-fast early exit. + """ + + status: str | None = None + timed_out: bool = False + early_exit: bool = False + failed_count: int = 0 + error_count: int = 0 + elapsed_seconds: float = 0.0 + success_override: bool | None = None + + @property + def success(self) -> bool | None: + """Return generic success for completed waits, or None for timeout/unknown.""" + + if self.success_override is not None: + return self.success_override + if self.timed_out: + return None + if self.early_exit or self.failed_count > 0 or self.error_count > 0: + return False + status = str(self.status or "").upper() + if status == "SUCCEEDED": + return True + if status in _TERMINAL_STATUSES: + return False + return None + + @staticmethod + def _count_statuses(status_counts: Mapping[str, Any], *statuses: str) -> int: + total = 0 + for status in statuses: + try: + total += int(status_counts.get(status, 0) or 0) + except (TypeError, ValueError): + continue + return total + + @classmethod + def _success_override_from_counts( + cls, + status_counts: Mapping[str, Any], + *, + terminal: bool, + total: int, + ) -> bool | None: + if not terminal or total <= 0: + return None + unsuccessful = cls._count_statuses( + status_counts, + "FAILED", + "SYSTEM_ERROR", + "CANCELLED", + "CANCELED", + "INVALID", + ) + if unsuccessful > 0: + return False + terminal_count = cls._count_statuses(status_counts, *_TERMINAL_STATUSES) + if terminal_count == total: + return True + return None + + @classmethod + def from_poll_result( + cls, + poll: "PipelineWaitPoll", + result: Mapping[str, Any], + ) -> "PipelineWaitOutcome": + """Build an outcome from a wait poll and public wait result.""" + + timed_out = bool(result.get("timed_out")) + early_exit = bool(result.get("early_exit")) + success_override = cls._success_override_from_counts( + poll.status_counts, + terminal=poll.terminal and not timed_out, + total=poll.total, + ) + if early_exit and poll.total == 0: + early_exit = False + success_override = False + return cls( + status=str(result.get("status")) if result.get("status") is not None else poll.status, + timed_out=timed_out, + early_exit=early_exit, + failed_count=int(poll.status_counts.get("FAILED", 0) or 0), + error_count=int(poll.status_counts.get("SYSTEM_ERROR", 0) or 0), + elapsed_seconds=poll.elapsed_seconds, + success_override=success_override, + ) + + @classmethod + def from_wait_result( + cls, + result: Mapping[str, Any], + metadata: Mapping[str, Any] | None = None, + ) -> "PipelineWaitOutcome": + """Build an outcome from a public wait result and optional metadata.""" + + source = metadata or result + status = str(result.get("status")) if result.get("status") is not None else None + timed_out = bool(result.get("timed_out") or source.get("timed_out")) + early_exit = bool(result.get("early_exit") or source.get("early_exit")) + status_counts = source.get("status_counts") + status_counts = status_counts if isinstance(status_counts, Mapping) else {} + total = 0 + for count in status_counts.values(): + try: + total += int(count or 0) + except (TypeError, ValueError): + continue + terminal = bool(status and (status.upper() == "ENDED" or status.upper() in _TERMINAL_STATUSES)) + success_override = cls._success_override_from_counts( + status_counts, + terminal=terminal and not timed_out, + total=total, + ) + if early_exit and total == 0: + early_exit = False + success_override = False + failed_count = int( + source.get( + "failed_count", + result.get("failed_count", cls._count_statuses(status_counts, "FAILED")), + ) + or 0 + ) + error_count = int( + source.get( + "error_count", + result.get("error_count", cls._count_statuses(status_counts, "SYSTEM_ERROR")), + ) + or 0 + ) + return cls( + status=status, + timed_out=timed_out, + early_exit=early_exit, + failed_count=failed_count, + error_count=error_count, + elapsed_seconds=float(source.get("elapsed_seconds", 0.0) or 0.0), + success_override=success_override, + ) + + +@dataclass +class PipelineRunContext: + """First-class context for a pipeline run lifecycle. + + Downstreams can use this for mutex ownership, graceful-shutdown state, + notifications, retries, and scheduled timeout bookkeeping without scraping + transient manager attributes. + + Fields: + run_id: Submitted pipeline run id, when an attempt reaches submit. + run_name: Display/pipeline name derived from the submitted spec. + root_execution_id: Root execution id returned by the submit API. + pipeline_path: Source path or URI used for the run, when path-backed. + start_time: Wall-clock attempt start time for downstream reporting. + attempt: 1-based attempt number for submit/wait/retry lifecycle hooks. + submit_body: Submit body for this attempt after normalization. + pipeline_spec: Pipeline spec extracted from ``submit_body``. + response: Submit API response for this attempt, when available. + wait_outcome: Generic wait result for this attempt, when wait ran. + previous_context: Previous attempt context, including attempts that + failed during submit before a ``run_id`` existed. This is not just + the previous successfully submitted run context. + previous_error: Error from the previous attempt that caused this retry. + carry_resource_to_retry: Generic resource/mutex handoff flag. Hooks set + this directly when a resource should remain held for the replacement + attempt. The current attempt's lifecycle context can then skip + release, and the next attempt can inspect ``previous_context`` to + reuse the carried resource. + metadata: Extra hook-specific state carried through the lifecycle. + """ + + run_id: str | None = None + run_name: str | None = None + root_execution_id: str | None = None + pipeline_path: str | Path | None = None + start_time: float | None = None + attempt: int = 1 + submit_body: dict[str, Any] | None = None + pipeline_spec: dict[str, Any] | None = None + response: dict[str, Any] | None = None + wait_outcome: PipelineWaitOutcome | None = None + previous_context: "PipelineRunContext | None" = None + previous_error: Exception | None = None + carry_resource_to_retry: bool = False + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PipelineWaitPoll: + """One wait-loop observation passed to lifecycle hooks.""" + + run_id: str + run: dict[str, Any] + status: str + status_counts: dict[str, int] + total: int + terminal: bool + graph_state: dict[str, Any] | None = None + elapsed_seconds: float = 0.0 + execution_state_timings: dict[str, dict[str, Any]] = field(default_factory=dict) + + +@dataclass +class PipelineRunHooks: + """Overridable seams for downstream tangle-deploy behavior. + + Subclasses can override these methods to add provider-specific auth wrappers, + cloud-object loading, JOB_CONFIG time input, run-as annotations, + mutex/schedule behavior, graceful shutdown, notifications, hosted logs, or + from-container runtime defaults without forking the generic pipeline-run manager. + """ + + logger: Logger = field(default_factory=get_default_logger) + trusted_python_sources: list[str] = field(default_factory=list) + allow_all_hydration: bool = False + + def read_pipeline_yaml(self, pipeline_path: str | Path) -> dict[str, Any]: + path_text = str(pipeline_path) + if path_text.startswith("gs://"): + raise UnsupportedPipelineRunFeatureError( + "gs:// pipeline loading is not supported by the OSS CLI default hooks" + ) + path = Path(pipeline_path) + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + raise PipelineRunError("Pipeline YAML must contain a top-level mapping") + return data + + def hydrate_pipeline( + self, + pipeline_path: str | Path, + *, + resolution_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + client = getattr(self, "client", None) + if client is None and hasattr(self, "_get_client"): + client = self._get_client() + if client is None: + raise PipelineRunError("Failed to create TangleApiClient") + hydrator = PipelineHydrator( + client=client, + resolution_overrides=resolution_overrides, + logger=self.logger, + trusted_python_sources=self.trusted_python_sources, + allow_all_hydration=self.allow_all_hydration, + ) + try: + return hydrator.hydrate_file(pipeline_path).data + except HydrationError as exc: + raise PipelineRunError(str(exc)) from exc + + def prepare_pipeline_spec( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path | None, + run_args: dict[str, Any] | None, + hydrate: bool, + ) -> dict[str, Any]: + """Hook for downstream validation/hydration/layout/annotation transforms. + + The default returns the already-loaded spec unchanged. TD can override + this to run schema validation, auto-layout, source annotations, or any + pre-submit preparation before the generic payload conversion runs. + """ + + return pipeline_spec + + def prepare_run_arguments( + self, + pipeline_spec: dict[str, Any], + run_args: dict[str, Any] | None, + ) -> dict[str, Any] | None: + """Hook for TD JOB_CONFIG time input / scheduled runtime behavior.""" + return run_args + + def transform_run_name( + self, + run_name: str, + *, + pipeline_spec: dict[str, Any], + run_args: dict[str, Any] | None, + ) -> str: + """Hook for downstream run-name policies after template expansion.""" + + return run_name + + def extra_submit_annotations( + self, + *, + pipeline_spec: dict[str, Any], + pipeline_path: str | Path | None, + run_as: str | None = None, + ) -> dict[str, str]: + """Hook for downstream source/run-as/git annotations.""" + if run_as: + raise UnsupportedPipelineRunFeatureError( + "--run-as is a downstream extension point and has no OSS default behavior" + ) + return {} + + def before_submit(self, pipeline_spec: dict[str, Any]) -> None: + """Legacy hook retained for compatibility with existing downstreams.""" + + def before_submit_context(self, context: PipelineRunContext) -> None: + """Hook for TD mutex/overlap checks with full run context.""" + + if context.pipeline_spec is not None: + self.before_submit(context.pipeline_spec) + + def after_submit(self, response: Mapping[str, Any]) -> None: + """Legacy hook retained for downstream start notifications.""" + + def after_submit_context(self, context: PipelineRunContext) -> None: + """Hook for downstream start notifications with full run context.""" + + if context.response is not None: + self.after_submit(context.response) + + def on_submit_error( + self, + error: Exception, + *, + context: PipelineRunContext, + ) -> None: + """Hook for downstream submit-error notifications/cleanup.""" + + def around_run(self, context: PipelineRunContext) -> AbstractContextManager[Any]: + """Context-manager seam for mutex/run lifecycle ownership.""" + + return nullcontext() + + def before_run_lifecycle(self, context: PipelineRunContext) -> None: + """Hook called before a run attempt enters the lifecycle context.""" + + def after_run_lifecycle( + self, + context: PipelineRunContext, + *, + success: bool, + error: Exception | None = None, + ) -> None: + """Hook called after the lifecycle context exits.""" + + def on_fail_fast_before_release( + self, + context: PipelineRunContext, + error: Exception, + ) -> None: + """Hook called before lifecycle release when fail-fast aborts a run.""" + + def before_retry( + self, + context: PipelineRunContext, + error: Exception, + *, + next_attempt: int, + ) -> None: + """Hook before retrying a failed submit/run attempt.""" + + def after_retry_submit(self, context: PipelineRunContext) -> None: + """Hook after a retry successfully submits a new run.""" + + def should_cancel_previous_run( + self, + context: PipelineRunContext, + error: Exception, + *, + next_attempt: int, + ) -> bool: + """Return True when retry should cancel the previous run first.""" + + return False + + def before_wait(self, context: PipelineRunContext) -> None: + """Hook called before polling a run.""" + + def after_poll(self, poll: PipelineWaitPoll, context: PipelineRunContext) -> None: + """Hook called after each run/graph-state poll.""" + + def should_exit_early(self, poll: PipelineWaitPoll, context: PipelineRunContext) -> bool: + """Return True to stop waiting before terminal/timeout. + + The generic fail-fast policy is opt-in via ``exit_on_first_failure``. + Downstreams can set that flag when they want the wait loop to return as + soon as a task fails, before the full graph reaches a terminal state. + """ + + if not context.metadata.get("exit_on_first_failure"): + return False + return any(int(poll.status_counts.get(status, 0) or 0) > 0 for status in _FAILURE_EARLY_EXIT_STATUSES) + + def on_timeout(self, poll: PipelineWaitPoll, context: PipelineRunContext) -> None: + """Hook called when wait reaches max_wait.""" + + def on_terminal(self, poll: PipelineWaitPoll, context: PipelineRunContext) -> None: + """Hook called when wait observes terminal state.""" + + def on_early_exit_before_release( + self, + poll: PipelineWaitPoll, + context: PipelineRunContext, + ) -> None: + """Hook called for fail-fast early exit before lifecycle release.""" + + def after_wait(self, result: Mapping[str, Any]) -> None: + """Legacy hook retained for terminal downstream notifications.""" + + def wait_outcome( + self, + poll: PipelineWaitPoll, + result: Mapping[str, Any], + context: PipelineRunContext, + ) -> PipelineWaitOutcome: + """Return the typed wait outcome to attach to the run context.""" + + del context + return PipelineWaitOutcome.from_poll_result(poll, result) + + def after_wait_context(self, result: Mapping[str, Any], context: PipelineRunContext) -> None: + """Hook called after wait returns with full run context. + + Preserve legacy behavior: ``after_wait(result)`` is called only for + terminal observations, not timeouts or fail-fast/early-exit returns. + Downstreams that need those outcomes should override ``on_timeout``, + ``on_early_exit_before_release``, or this context-aware hook directly. + """ + + if not result.get("timed_out") and not result.get("early_exit"): + status = result.get("status") + status_text = str(status).upper() if status else None + if status_text == "ENDED" or status_text in _TERMINAL_STATUSES: + self.after_wait(result) + + def should_enforce_max_wait(self, context: PipelineRunContext) -> bool: + """Return False for downstream-controlled scheduled timeout policies.""" + + return True + + def poll_run_snapshot( + self, + manager: "PipelineRunManager", + run_id: str, + context: PipelineRunContext, + ) -> Mapping[str, Any] | None: + """Optional hook to provide a run-like snapshot for wait polling. + + Downstreams whose wait API is rooted at an execution id can return a + synthetic run snapshot here instead of forcing the generic manager to + call ``pipeline_runs_get(run_id)``. + """ + + return None + + def graph_state_execution_id( + self, + run: Mapping[str, Any], + context: PipelineRunContext, + ) -> str | None: + """Return the execution id to use for graph-state polling.""" + + root_execution_id = run.get("root_execution_id") or context.root_execution_id + return str(root_execution_id) if root_execution_id is not None else None + + def on_poll_error(self, error: Exception, context: PipelineRunContext) -> float | None: + """Handle polling errors. + + Return a sleep interval to retry, or ``None`` to propagate the error. + """ + + return None + + def fetch_logs(self, client: Any, execution_id: str) -> Any: + """Hook for alternate TD log providers; OSS uses the Tangle API only.""" + return client.executions_container_log(execution_id) + + +@dataclass +class PipelineRunManager(TangleCliHandler): + client: Any + hooks: PipelineRunHooks = field(default_factory=PipelineRunHooks) + logger: Logger = field(default_factory=get_default_logger) + base_url: str | None = None + + def __post_init__(self) -> None: + TangleCliHandler.__init__( + self, + client=self.client, + logger=self.logger, + base_url=self.base_url, + ) + if self.hooks is not self: + setattr(self.hooks, "client", self.client) + + @staticmethod + def to_plain(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: PipelineRunManager.to_plain(val) for key, val in value.items()} + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "model_dump"): + return value.model_dump(by_alias=True) + if isinstance(value, list): + return [PipelineRunManager.to_plain(item) for item in value] + if hasattr(value, "__dict__"): + return { + key: PipelineRunManager.to_plain(val) + for key, val in vars(value).items() + if not key.startswith("_") + } + return value + + @staticmethod + def extract_default_arguments(pipeline_spec: dict[str, Any]) -> dict[str, Any]: + arguments: dict[str, Any] = {} + inputs = pipeline_spec.get("inputs", []) + if isinstance(inputs, list): + for input_item in inputs: + if isinstance(input_item, dict) and "name" in input_item and "default" in input_item: + arguments[input_item["name"]] = input_item["default"] + return arguments + + @staticmethod + def convert_yaml_to_payload( + pipeline_spec: dict[str, Any], + run_args: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"root_task": {"componentRef": {"spec": pipeline_spec}}} + arguments = PipelineRunManager.extract_default_arguments(pipeline_spec) + if run_args: + arguments.update(run_args) + + pipeline_inputs = pipeline_spec.get("inputs", []) + valid_inputs = {inp.get("name") for inp in pipeline_inputs if isinstance(inp, dict) and inp.get("name")} + if valid_inputs: + arguments = {key: value for key, value in arguments.items() if key in valid_inputs} + + missing: list[str] = [] + for input_item in pipeline_inputs if isinstance(pipeline_inputs, list) else []: + if not isinstance(input_item, dict): + continue + name = input_item.get("name") + if name and "default" not in input_item and not input_item.get("optional", False) and name not in arguments: + missing.append(name) + if missing: + raise PipelineRunError( + f"Missing {len(missing)} required pipeline input(s): {', '.join(sorted(missing))}" + ) + + if arguments: + payload["root_task"]["arguments"] = arguments + return payload + + @staticmethod + def sanitize_submit_payload(value: Any) -> Any: + """Return a submit-safe payload with TD-compatible componentRef fixes. + + The hydrator uses explicit local-only annotations such as + ``_source_dir`` while recursively resolving local files. Those + provenance keys must not be submitted to the backend. User-supplied + underscore-prefixed payload keys are otherwise valid and preserved. + TD also normalizes ``componentRef.text`` into ``componentRef.spec`` + for component-library entries before submit; keep the same behavior + here. + """ + + if isinstance(value, list): + return [PipelineRunManager.sanitize_submit_payload(item) for item in value] + if not isinstance(value, dict): + return value + + local_only_keys = {"_source_dir", "_recursive_params"} + cleaned: dict[str, Any] = {} + for key, item in value.items(): + if str(key) in local_only_keys: + continue + cleaned[key] = PipelineRunManager.sanitize_submit_payload(item) + + component_ref = cleaned.get("componentRef") + if isinstance(component_ref, dict) and "text" in component_ref and not component_ref.get("spec"): + text_content = component_ref.pop("text") + if isinstance(text_content, str): + try: + component_ref["spec"] = yaml.safe_load(text_content) + except yaml.YAMLError as exc: + component_name = component_ref.get("name", "unknown") + raise PipelineRunError( + f"Failed to parse YAML in componentRef {component_name!r}: {exc}" + ) from exc + else: + component_ref["spec"] = text_content + component_ref["spec"] = PipelineRunManager.sanitize_submit_payload(component_ref["spec"]) + + return cleaned + + @staticmethod + def normalize_submit_body_in_place(body: dict[str, Any]) -> dict[str, Any]: + """Normalize a submit body in place and return it. + + This is the mutable counterpart to :meth:`sanitize_submit_payload` for + callers that already have a body object. It keeps component-ref text + normalization and submit-only field stripping in the OSS submit layer, + instead of requiring downstream runners to patch bodies before submit. + """ + + sanitized = PipelineRunManager.sanitize_submit_payload(body) + if not isinstance(sanitized, dict): + raise PipelineRunError("submit body must be a mapping") + body.clear() + body.update(sanitized) + return body + + @staticmethod + def is_terminal_status(status: str | None) -> bool: + return bool(status and status.upper() in _TERMINAL_STATUSES) + + @staticmethod + def status_counts_from_run(run: Mapping[str, Any]) -> dict[str, int]: + stats = run.get("execution_status_stats") + if not isinstance(stats, Mapping): + return {} + result: dict[str, int] = {} + for key, value in stats.items(): + try: + result[str(key).upper()] = int(value or 0) + except (TypeError, ValueError): + continue + return result + + @staticmethod + def _counts_mapping(value: Any) -> Mapping[str, Any] | None: + if isinstance(value, Mapping): + return value + if value is not None and hasattr(value, "items"): + return value + return None + + @staticmethod + def status_counts_from_graph_state(graph_state: Mapping[str, Any] | Any) -> dict[str, int]: + for key in ("status_totals", "execution_status_stats"): + stats = graph_state.get(key) if isinstance(graph_state, Mapping) else getattr(graph_state, key, None) + counts = PipelineRunManager._counts_mapping(stats) + if counts is not None: + return { + str(status).upper(): int(count or 0) + for status, count in counts.items() + } + child_stats = ( + graph_state.get("child_execution_status_stats") + if isinstance(graph_state, Mapping) + else getattr(graph_state, "child_execution_status_stats", None) + ) + totals: dict[str, int] = {} + child_counts = PipelineRunManager._counts_mapping(child_stats) + if child_counts is not None: + for stats in child_counts.values(): + counts = PipelineRunManager._counts_mapping(stats) + if counts is None: + continue + for status, count in counts.items(): + totals[str(status).upper()] = totals.get(str(status).upper(), 0) + int(count or 0) + return totals + + @staticmethod + def execution_status_counts_from_graph_state(graph_state: Mapping[str, Any] | Any) -> dict[str, dict[str, int]]: + """Return per-execution status counts from a graph-state response.""" + + child_stats = ( + graph_state.get("child_execution_status_stats") + if isinstance(graph_state, Mapping) + else getattr(graph_state, "child_execution_status_stats", None) + ) + child_counts = PipelineRunManager._counts_mapping(child_stats) + if child_counts is None: + return {} + result: dict[str, dict[str, int]] = {} + for execution_id, stats in child_counts.items(): + counts = PipelineRunManager._counts_mapping(stats) + if counts is None: + continue + status_counts: dict[str, int] = {} + for status, count in counts.items(): + try: + status_counts[str(status).upper()] = int(count or 0) + except (TypeError, ValueError): + continue + result[str(execution_id)] = status_counts + return result + + @staticmethod + def status_from_counts(status_counts: Mapping[str, int]) -> str | None: + for status in _ACTIVE_STATUSES: + if int(status_counts.get(status, 0) or 0) > 0: + return status + for status in _TERMINAL_STATUSES: + if int(status_counts.get(status, 0) or 0) > 0: + return status + return None + + @staticmethod + def status_from_run(run: Mapping[str, Any]) -> str | None: + summary = run.get("execution_summary") + if isinstance(summary, Mapping) and summary.get("has_ended") is True: + stats = run.get("execution_status_stats") + if isinstance(stats, Mapping): + for status in ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED"): + if int(stats.get(status, 0) or 0) > 0: + return status + if int(stats.get("SUCCEEDED", 0) or 0) > 0: + return "SUCCEEDED" + return "ENDED" + stats = run.get("execution_status_stats") + if isinstance(stats, Mapping): + for status in _ACTIVE_STATUSES: + if int(stats.get(status, 0) or 0) > 0: + return status + for status in _TERMINAL_STATUSES: + if int(stats.get(status, 0) or 0) > 0: + return status + return None + + @staticmethod + def _accepts_client_keyword(method: Any) -> bool: + try: + parameters = inspect.signature(method).parameters + except (TypeError, ValueError): + return False + return "client" in parameters or any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in parameters.values() + ) + + def load_pipeline_for_submit( + self, + pipeline_path: str | Path, + *, + hydrate: bool = True, + resolution_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if hydrate: + hydrate_pipeline = self.hooks.hydrate_pipeline + hydrate_kwargs: dict[str, Any] = {"resolution_overrides": resolution_overrides} + if self._accepts_client_keyword(hydrate_pipeline): + hydrate_kwargs["client"] = self._get_client() + return hydrate_pipeline(pipeline_path, **hydrate_kwargs) + return self.hooks.read_pipeline_yaml(pipeline_path) + + @staticmethod + def expand_run_name_template( + template: str, + pipeline_spec: dict[str, Any], + run_args: dict[str, Any] | None = None, + ) -> str: + """Expand ``${arguments.NAME}`` placeholders from defaults + run args.""" + + arguments = PipelineRunManager.extract_default_arguments(pipeline_spec) + if run_args: + arguments.update(run_args) + + def replace_placeholder(match: re.Match[str]) -> str: + value = arguments.get(match.group(1)) + return str(value) if value is not None else match.group(0) + + return re.sub(r"\$\{arguments\.([^}]+)\}", replace_placeholder, template) + + def apply_run_name_template( + self, + pipeline_spec: dict[str, Any], + run_args: dict[str, Any] | None = None, + ) -> dict[str, Any]: + annotations = pipeline_spec.get("metadata", {}).get("annotations", {}) + template = annotations.get("run-name-template") if isinstance(annotations, Mapping) else None + if not template: + return pipeline_spec + transformed = copy.deepcopy(pipeline_spec) + expanded = self.expand_run_name_template(str(template), transformed, run_args) + transformed["name"] = self.hooks.transform_run_name( + expanded, + pipeline_spec=transformed, + run_args=run_args, + ) + return transformed + + def prepare_pipeline_spec_for_submit( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path | None = None, + run_args: dict[str, Any] | None = None, + hydrate: bool = True, + ) -> dict[str, Any]: + return self.hooks.prepare_pipeline_spec( + pipeline_spec, + pipeline_path=pipeline_path, + run_args=run_args, + hydrate=hydrate, + ) + + def prepare_submit_payload_from_spec( + self, + pipeline_spec: dict[str, Any], + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + pipeline_path: str | Path | None = None, + run_as: str | None = None, + hydrate: bool = True, + ) -> PipelineSubmitPayload: + """Prepare the generic submit payload from a pipeline spec. + + The order here is the submit-body contract shared by OSS and TD: + prepare the spec, prepare runtime arguments, expand run-name templates, + convert/sanitize the payload, then merge downstream/default annotations + before caller-supplied annotations override them. + """ + + prepared_spec = self.prepare_pipeline_spec_for_submit( + pipeline_spec, + pipeline_path=pipeline_path, + run_args=run_args, + hydrate=hydrate, + ) + prepared_run_args = self.hooks.prepare_run_arguments(prepared_spec, run_args) + prepared_spec = self.apply_run_name_template(prepared_spec, prepared_run_args) + payload = self.convert_yaml_to_payload(copy.deepcopy(prepared_spec), prepared_run_args) + payload = self.sanitize_submit_payload(payload) + root_task = payload["root_task"] + component_ref = root_task.get("componentRef") if isinstance(root_task, Mapping) else None + submit_spec = ( + component_ref.get("spec") + if isinstance(component_ref, Mapping) and isinstance(component_ref.get("spec"), dict) + else prepared_spec + ) + submit_annotations = self.hooks.extra_submit_annotations( + pipeline_spec=prepared_spec, + pipeline_path=pipeline_path, + run_as=run_as, + ) + if annotations: + submit_annotations.update({str(k): str(v) for k, v in annotations.items()}) + run_name = submit_spec.get("name") + return PipelineSubmitPayload( + prepared_spec=prepared_spec, + pipeline_spec=submit_spec, + run_args=prepared_run_args, + root_task=root_task, + annotations=submit_annotations, + run_name=run_name if isinstance(run_name, str) and run_name else None, + ) + + def build_submit_body_from_spec( + self, + pipeline_spec: dict[str, Any], + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + pipeline_path: str | Path | None = None, + run_as: str | None = None, + hydrate: bool = True, + ) -> dict[str, Any]: + """Build a submit body from an already-prepared pipeline spec.""" + + return self.prepare_submit_payload_from_spec( + pipeline_spec, + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=hydrate, + ).to_body() + + def prepare_submit_payload( + self, + pipeline_path: str | Path, + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + hydrate: bool = True, + run_as: str | None = None, + resolution_overrides: dict[str, Any] | None = None, + ) -> PipelineSubmitPayload: + pipeline_spec = self.load_pipeline_for_submit( + pipeline_path, + hydrate=hydrate, + resolution_overrides=resolution_overrides, + ) + return self.prepare_submit_payload_from_spec( + pipeline_spec, + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=hydrate, + ) + + def build_submit_body( + self, + pipeline_path: str | Path, + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + hydrate: bool = True, + run_as: str | None = None, + resolution_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self.prepare_submit_payload( + pipeline_path, + run_args=run_args, + annotations=annotations, + hydrate=hydrate, + run_as=run_as, + resolution_overrides=resolution_overrides, + ).to_body() + + @staticmethod + def response_run_context( + response: Mapping[str, Any], + *, + submit_body: dict[str, Any], + pipeline_path: str | Path | None = None, + attempt: int = 1, + ) -> PipelineRunContext: + pipeline_spec = submit_body.get("root_task", {}).get("componentRef", {}).get("spec") + run_name = pipeline_spec.get("name") if isinstance(pipeline_spec, dict) else None + return PipelineRunContext( + run_id=str(response.get("id")) if response.get("id") is not None else None, + run_name=run_name if isinstance(run_name, str) and run_name else None, + root_execution_id=( + str(response.get("root_execution_id")) + if response.get("root_execution_id") is not None + else None + ), + pipeline_path=pipeline_path, + start_time=time.time(), + attempt=attempt, + submit_body=submit_body, + pipeline_spec=pipeline_spec if isinstance(pipeline_spec, dict) else None, + response=dict(response), + ) + + def submit_prepared_body( + self, + body: dict[str, Any], + *, + pipeline_path: str | Path | None = None, + attempt: int = 1, + context: PipelineRunContext | None = None, + notify_submit_error: bool = True, + ) -> dict[str, Any]: + self.normalize_submit_body_in_place(body) + pipeline_spec = body["root_task"]["componentRef"]["spec"] + submit_context = context or PipelineRunContext( + pipeline_path=pipeline_path, + start_time=time.time(), + attempt=attempt, + ) + spec_name = pipeline_spec.get("name") if isinstance(pipeline_spec, dict) else None + submit_context.run_name = spec_name if isinstance(spec_name, str) and spec_name else None + submit_context.pipeline_path = pipeline_path + submit_context.attempt = attempt + submit_context.submit_body = body + submit_context.pipeline_spec = pipeline_spec if isinstance(pipeline_spec, dict) else None + self.hooks.before_submit_context(submit_context) + client = self._require_client() + try: + response = self.to_plain(client.pipeline_runs_create(body=body)) + except Exception as exc: + if notify_submit_error: + self.hooks.on_submit_error(exc, context=submit_context) + raise + if not isinstance(response, dict): + response = {} + submitted_context = self.response_run_context( + response, + submit_body=body, + pipeline_path=pipeline_path, + attempt=attempt, + ) + submit_context.run_id = submitted_context.run_id + submit_context.run_name = submitted_context.run_name + submit_context.root_execution_id = submitted_context.root_execution_id + submit_context.submit_body = submitted_context.submit_body + submit_context.pipeline_spec = submitted_context.pipeline_spec + submit_context.response = response + self.hooks.after_submit_context(submit_context) + return response + + def submit_prepared_payload( + self, + payload: PipelineSubmitPayload, + *, + pipeline_path: str | Path | None = None, + attempt: int = 1, + context: PipelineRunContext | None = None, + ) -> dict[str, Any]: + body = payload.to_body() + response = self.submit_prepared_body( + body, + pipeline_path=pipeline_path, + attempt=attempt, + context=context, + ) + payload.sync_from_body(body) + return response + + def submit_pipeline_spec( + self, + pipeline_spec: dict[str, Any], + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + pipeline_path: str | Path | None = None, + run_as: str | None = None, + hydrate: bool = True, + attempt: int = 1, + ) -> dict[str, Any]: + payload = self.prepare_submit_payload_from_spec( + pipeline_spec, + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=hydrate, + ) + return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt) + + def submit_pipeline( + self, + pipeline_path: str | Path, + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + hydrate: bool = True, + run_as: str | None = None, + resolution_overrides: dict[str, Any] | None = None, + attempt: int = 1, + ) -> dict[str, Any]: + payload = self.prepare_submit_payload( + pipeline_path, + run_args=run_args, + annotations=annotations, + hydrate=hydrate, + run_as=run_as, + resolution_overrides=resolution_overrides, + ) + return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt) + + def get_run(self, run_id: str, *, include_execution_stats: bool = True) -> dict[str, Any]: + return self.to_plain( + self.client.pipeline_runs_get( + run_id, + include_execution_stats=include_execution_stats, + ) + ) + + def get_run_details( + self, + run_id: str, + *, + include_annotations: bool = False, + include_execution_state: bool = False, + include_implementations: bool = False, + execution_id: str | None = None, + ) -> dict[str, Any]: + return PipelineRunDetails(client=self.client).get_run_details_output( + run_id, + include_implementations=include_implementations, + include_annotations=include_annotations, + include_execution_state=include_execution_state, + execution_id=execution_id, + ) + + def cancel_run(self, run_id: str) -> dict[str, Any]: + return self.to_plain(self.client.pipeline_runs_cancel(run_id)) or {"id": run_id, "cancelled": True} + + def graph_state(self, execution_id: str) -> Mapping[str, Any] | Any: + graph_state = self.client.executions_graph_execution_state(execution_id) + return self.to_plain(graph_state) + + def graph_state_output(self, run_ids: list[str], *, timeout: float = 30.0) -> dict[str, Any]: + return PipelineRunDetails(client=self.client).get_graph_state_output(run_ids, timeout=timeout) + + def logs(self, execution_id: str) -> dict[str, Any]: + return self.to_plain(self.hooks.fetch_logs(self.client, execution_id)) + + def search_runs( + self, + *, + filter: str | None = None, + filter_query: str | None = None, + page_token: str | None = None, + include_pipeline_names: bool | None = None, + include_execution_stats: bool | None = True, + ) -> dict[str, Any]: + return self.to_plain( + self.client.pipeline_runs_list( + page_token=page_token, + filter=filter, + filter_query=filter_query, + include_pipeline_names=include_pipeline_names, + include_execution_stats=include_execution_stats, + ) + ) + + def search_pipeline_runs( + self, + *, + name: str | None = None, + created_by: str | None = None, + annotations: dict[str, str | None] | None = None, + start_date: str | None = None, + end_date: str | None = None, + local_time: bool = False, + query: dict[str, Any] | None = None, + limit: int = 10, + page_token: str | None = None, + ) -> dict[str, Any]: + return PipelineRunSearch(client=self.client, logger=self.logger).search( + name=name, + created_by=created_by, + annotations=annotations, + start_date=start_date, + end_date=end_date, + local_time=local_time, + query=query, + limit=limit, + page_token=page_token, + ) + + def export_run( + self, + run_id: str, + output: str | Path | None = None, + *, + dehydrate: bool = False, + ) -> dict[str, Any]: + task_spec = self.client.get_run_pipeline_spec(run_id) + if task_spec is None: + raise PipelineRunError(f"No pipeline spec found for run {run_id}") + raw = getattr(task_spec, "raw", None) + if isinstance(raw, Mapping): + spec = raw.get("componentRef", {}).get("spec") + else: + spec = None + component_spec = getattr(task_spec, "component_spec", None) + if not isinstance(spec, dict) and component_spec is not None: + spec = getattr(component_spec, "data", None) + if not isinstance(spec, dict) or not spec: + raise PipelineRunError(f"Pipeline spec for run {run_id} is not exportable") + if dehydrate and output is None: + raise PipelineRunError("--dehydrate requires --output") + if dehydrate: + spec = PipelineDehydrator( + remembered_choices={"": DehydrateChoice.AUTO}, + output_file=output, + client=self.client, + logger=self.logger, + ).dehydrate(spec) + content = dump_yaml(spec) + if output is None: + return {"run_id": run_id, "pipeline": spec, "yaml": content, "dehydrated": dehydrate} + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + + result = {"run_id": run_id, "output": str(output_path), "dehydrated": dehydrate} + arguments = self.to_plain(getattr(task_spec, "arguments", None) or {}) + if not arguments and isinstance(raw, Mapping): + arguments = self.to_plain(raw.get("arguments") or {}) + if isinstance(arguments, Mapping) and (arguments or dehydrate): + config_path = output_path.parent / f"{output_path.stem}.config.yaml" + config_data: dict[str, Any] = {"pipeline_path": output_path.name} + if dehydrate: + config_data["hydrate"] = True + if arguments: + config_data["args"] = dict(arguments) + config_path.write_text(dump_yaml(config_data), encoding="utf-8") + result["config_path"] = str(config_path) + return result + + def _update_execution_state_timings( + self, + context: PipelineRunContext, + graph_state: Mapping[str, Any] | Any, + ) -> dict[str, dict[str, Any]]: + """Track how long each execution has stayed in its observed state.""" + + execution_status_counts = self.execution_status_counts_from_graph_state(graph_state) + if not execution_status_counts: + context.metadata[_EXECUTION_STATE_TIMINGS_METADATA_KEY] = {} + context.metadata[_EXECUTION_STATE_TIMING_MONOTONIC_METADATA_KEY] = {} + return {} + + existing_value = context.metadata.get(_EXECUTION_STATE_TIMINGS_METADATA_KEY) + existing = existing_value if isinstance(existing_value, Mapping) else {} + monotonic_value = context.metadata.get(_EXECUTION_STATE_TIMING_MONOTONIC_METADATA_KEY) + monotonic_state_entered = monotonic_value if isinstance(monotonic_value, Mapping) else {} + now_wall = time.time() + now_monotonic = time.monotonic() + timings: dict[str, dict[str, Any]] = {} + next_monotonic_state_entered: dict[str, float] = {} + + for execution_id, status_counts in execution_status_counts.items(): + state = self.status_from_counts(status_counts) or "UNKNOWN" + existing_record = existing.get(execution_id) + previous = existing_record if isinstance(existing_record, Mapping) else {} + previous_state = previous.get("state") + if previous_state == state: + try: + state_entered_at = float(previous.get("state_entered_at", now_wall)) + except (TypeError, ValueError): + state_entered_at = now_wall + try: + state_entered_monotonic = float(monotonic_state_entered.get(execution_id, now_monotonic)) + except (TypeError, ValueError): + state_entered_monotonic = now_monotonic + else: + state_entered_at = now_wall + state_entered_monotonic = now_monotonic + + timings[execution_id] = { + "state": state, + "state_entered_at": state_entered_at, + "elapsed_seconds": max(0.0, now_monotonic - state_entered_monotonic), + "last_observed_at": now_wall, + } + next_monotonic_state_entered[execution_id] = state_entered_monotonic + + context.metadata[_EXECUTION_STATE_TIMINGS_METADATA_KEY] = timings + context.metadata[_EXECUTION_STATE_TIMING_MONOTONIC_METADATA_KEY] = next_monotonic_state_entered + return copy.deepcopy(timings) + + def _poll_run_status( + self, + run_id: str, + *, + use_graph_state: bool, + started_at: float, + context: PipelineRunContext | None = None, + ) -> PipelineWaitPoll: + wait_context = context or PipelineRunContext(run_id=run_id, start_time=time.time()) + run_snapshot = self.hooks.poll_run_snapshot(self, run_id, wait_context) + run = self.to_plain(run_snapshot) if run_snapshot is not None else self.get_run( + run_id, include_execution_stats=True + ) + if not isinstance(run, dict): + run = {} + graph_state: dict[str, Any] | None = None + execution_state_timings: dict[str, dict[str, Any]] = {} + status_counts = self.status_counts_from_run(run) + if use_graph_state: + root_execution_id = self.hooks.graph_state_execution_id(run, wait_context) + if root_execution_id: + graph_state = self.graph_state(str(root_execution_id)) + graph_counts = self.status_counts_from_graph_state(graph_state) + if graph_counts: + status_counts = graph_counts + execution_state_timings = self._update_execution_state_timings(wait_context, graph_state) + status = self.status_from_counts(status_counts) or self.status_from_run(run) or "UNKNOWN" + terminal = self.is_terminal_status(status) or status == "ENDED" + total = sum(status_counts.values()) + if total and use_graph_state: + terminal_count = sum(status_counts.get(state, 0) for state in _TERMINAL_STATUSES) + terminal = terminal_count == total + return PipelineWaitPoll( + run_id=run_id, + run=run, + status=status, + status_counts=status_counts, + total=total, + terminal=terminal, + graph_state=graph_state if isinstance(graph_state, dict) else None, + elapsed_seconds=time.monotonic() - started_at, + execution_state_timings=execution_state_timings, + ) + + def wait_for_completion( + self, + run_id: str, + *, + max_wait: float | None, + poll_interval: float, + use_graph_state: bool = False, + context: PipelineRunContext | None = None, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", + exit_on_first_failure: bool = False, + ) -> dict[str, Any]: + wait_context = context or PipelineRunContext(run_id=run_id, start_time=time.time()) + if exit_on_first_failure: + wait_context.metadata["exit_on_first_failure"] = True + if max_wait is not None and max_wait < 0: + raise PipelineRunError("--max-wait must be non-negative") + if poll_interval < 0 or (poll_interval == 0 and not allow_zero_poll_interval): + raise PipelineRunError("--poll-interval must be positive") + if timeout_clock not in {"monotonic", "wall"}: + raise PipelineRunError("timeout_clock must be 'monotonic' or 'wall'") + enforce_max_wait = max_wait is not None and self.hooks.should_enforce_max_wait(wait_context) + poll_started_at = time.monotonic() + deadline_now: Callable[[], float] = time.time if timeout_clock == "wall" else time.monotonic + deadline_started_at = deadline_now() + deadline = deadline_started_at + max_wait if enforce_max_wait else None + self.hooks.before_wait(wait_context) + last_poll: PipelineWaitPoll | None = None + while True: + try: + poll = self._poll_run_status( + run_id, + use_graph_state=use_graph_state, + started_at=poll_started_at, + context=wait_context, + ) + except KeyboardInterrupt: + raise + except Exception as exc: + if deadline is not None and deadline_now() >= deadline: + raise PipelineRunError(f"Timed out waiting for run {run_id}") from exc + retry_interval = self.hooks.on_poll_error(exc, wait_context) + if retry_interval is None: + raise + if deadline is not None: + remaining = deadline - deadline_now() + if remaining <= 0: + raise PipelineRunError(f"Timed out waiting for run {run_id}") from exc + retry_interval = min(retry_interval, remaining) + time.sleep(max(0.0, retry_interval)) + continue + last_poll = poll + self.hooks.after_poll(poll, wait_context) + if poll.terminal: + wait_context.metadata["wait_result"] = self._wait_metadata(poll) + self.hooks.on_terminal(poll, wait_context) + result = self._wait_result(poll, timed_out=False) + self._record_wait_outcome(wait_context, poll, result) + self.hooks.after_wait_context(result, wait_context) + return result + if self.hooks.should_exit_early(poll, wait_context): + wait_context.metadata["wait_result"] = self._wait_metadata(poll, early_exit=True) + self.hooks.on_early_exit_before_release(poll, wait_context) + result = self._wait_result(poll, timed_out=False, early_exit=True) + self._record_wait_outcome(wait_context, poll, result) + self.hooks.after_wait_context(result, wait_context) + return result + if deadline is not None and deadline_now() >= deadline: + wait_context.metadata["wait_result"] = self._wait_metadata(poll, timed_out=True) + self.hooks.on_timeout(poll, wait_context) + result = self._wait_result(poll, timed_out=True) + self._record_wait_outcome(wait_context, poll, result) + self.hooks.after_wait_context(result, wait_context) + return result + if deadline is None: + sleep_for = poll_interval + else: + sleep_for = min(poll_interval, max(0.0, deadline - deadline_now())) + time.sleep(sleep_for) + if last_poll is None: # pragma: no cover - defensive, loop always polls first + raise PipelineRunError(f"No status returned for run {run_id}") + + @staticmethod + def _wait_metadata( + poll: PipelineWaitPoll, + *, + timed_out: bool = False, + early_exit: bool = False, + ) -> dict[str, Any]: + failed_count = int(poll.status_counts.get("FAILED", 0) or 0) + error_count = int(poll.status_counts.get("SYSTEM_ERROR", 0) or 0) + metadata: dict[str, Any] = { + "status_counts": dict(poll.status_counts), + "failed_count": failed_count, + "error_count": error_count, + "elapsed_seconds": poll.elapsed_seconds, + } + if timed_out: + metadata["timed_out"] = True + if early_exit: + metadata["early_exit"] = True + return metadata + + def _record_wait_outcome( + self, + context: PipelineRunContext, + poll: PipelineWaitPoll, + result: Mapping[str, Any], + ) -> None: + context.wait_outcome = self.hooks.wait_outcome(poll, result, context) + + @staticmethod + def _wait_result( + poll: PipelineWaitPoll, + *, + timed_out: bool, + early_exit: bool = False, + ) -> dict[str, Any]: + result: dict[str, Any] = { + "run": poll.run, + "status": poll.status, + "timed_out": timed_out, + } + if early_exit or timed_out: + result.update(PipelineRunManager._wait_metadata(poll, timed_out=timed_out, early_exit=early_exit)) + if early_exit: + result["early_exit"] = True + return result + + @staticmethod + def _ensure_submission_id_annotation(body: dict[str, Any]) -> str: + annotations = body.setdefault("annotations", {}) + if not isinstance(annotations, dict): + annotations = {} + body["annotations"] = annotations + submission_id = annotations.get(_SUBMISSION_ID_ANNOTATION_KEY) + if submission_id: + annotations[_SUBMISSION_ID_ANNOTATION_KEY] = str(submission_id) + return str(submission_id) + submission_id = uuid.uuid4().hex + annotations[_SUBMISSION_ID_ANNOTATION_KEY] = submission_id + return submission_id + + @staticmethod + def _submission_id_from_body(body: Mapping[str, Any]) -> str | None: + annotations = body.get("annotations") + if not isinstance(annotations, Mapping): + return None + submission_id = annotations.get(_SUBMISSION_ID_ANNOTATION_KEY) + return str(submission_id) if submission_id else None + + def _submitted_runs_for_submission_id(self, submission_id: str) -> list[dict[str, Any]]: + query = { + "and": [ + PipelineRunSearch.build_value_equals( + key=_SUBMISSION_ID_ANNOTATION_KEY, + value=submission_id, + ) + ] + } + response = self._require_client().pipeline_runs_list( + filter_query=json.dumps(query, separators=(",", ":")), + include_pipeline_names=True, + ) + plain = self.to_plain(response) + if not isinstance(plain, Mapping): + return [] + runs = plain.get("pipeline_runs") + if not isinstance(runs, list): + return [] + return [dict(run) for run in runs if isinstance(run, Mapping)] + + def _recover_submitted_run_after_submit_error( + self, + *, + submission_id: str | None, + ) -> dict[str, Any] | None: + if not submission_id: + return None + for lookup_attempt in range(1, _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS + 1): + self.logger.info( + "Checking whether failed submit already created a pipeline run " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, " + f"lookup_attempt={lookup_attempt}/{_SUBMIT_RECOVERY_LOOKUP_ATTEMPTS})" + ) + try: + matches = self._submitted_runs_for_submission_id(submission_id) + except Exception as exc: + self.logger.warn( + "Submit recovery lookup failed " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}): {exc}. " + "Falling back to resubmitting the same frozen body." + ) + return None + self.logger.info( + "Submit recovery lookup matched " + f"{len(matches)} run(s) for {_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}" + ) + if len(matches) == 1: + run = matches[0] + run_id = run.get("id") + root_execution_id = run.get("root_execution_id") + self.logger.info( + "Recovered existing pipeline run " + f"run_id={run_id}, root_execution_id={root_execution_id}, " + f"{_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}; adopting instead of resubmitting." + ) + return run + if len(matches) > 1: + run_ids = [str(run.get("id")) for run in matches if run.get("id") is not None] + self.logger.warn( + "Submit recovery lookup was ambiguous " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, matched_run_ids={run_ids}). " + "Refusing to submit a duplicate." + ) + raise AmbiguousPipelineRunRecoveryError( + "Found multiple pipeline runs for failed submit recovery " + f"{_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}: {', '.join(run_ids) or matches!r}. " + "Refusing to submit a duplicate." + ) + if lookup_attempt < _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS: + time.sleep(_SUBMIT_RECOVERY_LOOKUP_DELAY_SECONDS) + self.logger.warn( + "No existing pipeline run found after submit failure " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}); " + "resubmitting the same frozen body with preserved inputs." + ) + return None + + def _adopt_submitted_run( + self, + *, + response: Mapping[str, Any], + body: dict[str, Any], + pipeline_path: str | Path | None, + attempt: int, + context: PipelineRunContext, + ) -> dict[str, Any]: + response_dict = dict(response) + submitted_context = self.response_run_context( + response_dict, + submit_body=body, + pipeline_path=pipeline_path, + attempt=attempt, + ) + context.run_id = submitted_context.run_id + context.run_name = submitted_context.run_name + context.root_execution_id = submitted_context.root_execution_id + context.submit_body = submitted_context.submit_body + context.pipeline_spec = submitted_context.pipeline_spec + context.response = response_dict + context.metadata["recovered_after_submit_error"] = True + self.hooks.after_submit_context(context) + return response_dict + + def _run_body_factory( + self, + body_factory: Callable[[int, PipelineRunContext | None, Exception | None], dict[str, Any]], + *, + pipeline_path: str | Path | None = None, + wait: bool = False, + max_wait: float | None = 600.0, + poll_interval: float = 10.0, + use_graph_state: bool = False, + max_attempts: int = 1, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", + exit_on_first_failure: bool = False, + metadata: dict[str, Any] | None = None, + metadata_factory: Callable[ + [int, PipelineRunContext | None, Exception | None], dict[str, Any] + ] | None = None, + ) -> dict[str, Any]: + """Drive submit/wait/retry for already prepared specs or submit bodies.""" + + if max_attempts < 1: + raise PipelineRunError("max_attempts must be at least 1") + last_error: Exception | None = None + previous_context: PipelineRunContext | None = None + attempts: list[PipelineRunContext] = [] + for attempt in range(1, max_attempts + 1): + context = PipelineRunContext( + pipeline_path=pipeline_path, + start_time=time.time(), + attempt=attempt, + previous_context=previous_context, + previous_error=last_error, + metadata=dict(metadata or {}), + ) + lifecycle_started = False + success = False + error: Exception | None = None + retry_requested = False + reused_after_submit_failure = ( + previous_context is not None + and previous_context.run_id is None + and previous_context.submit_body is not None + ) + if reused_after_submit_failure: + # The previous attempt failed while submitting, before the API + # returned a run id. Retry the exact same submit body instead + # of rebuilding it: body construction can intentionally inject + # dynamic inputs (for example a scheduler creation timestamp), + # and changing those inputs on an ambiguous submit timeout can + # defeat cache reuse or double-run the logical pipeline. + body = copy.deepcopy(previous_context.submit_body) + self.logger.info( + "Retrying submit after submit exception with the same frozen body " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={self._submission_id_from_body(body)}); " + "dynamic inputs are preserved." + ) + else: + if previous_context is not None: + self.logger.info( + "Retrying after pipeline failure; rebuilding submit body so dynamic run arguments " + "can follow hook policy (for example update-vs-fixed time input)." + ) + body = body_factory(attempt, previous_context, last_error) + self.normalize_submit_body_in_place(body) + submission_id = self._ensure_submission_id_annotation(body) + context.metadata["submission_id"] = submission_id + if metadata_factory is not None: + context.metadata.update(metadata_factory(attempt, previous_context, last_error)) + pipeline_spec = body.get("root_task", {}).get("componentRef", {}).get("spec") + context.submit_body = body + context.pipeline_spec = pipeline_spec if isinstance(pipeline_spec, dict) else None + if context.pipeline_spec is not None: + spec_name = context.pipeline_spec.get("name") + if isinstance(spec_name, str) and spec_name: + context.run_name = spec_name + self.hooks.before_run_lifecycle(context) + lifecycle_started = True + attempts.append(context) + # ``previous_context`` tracks the previous attempt, not only the + # previous successfully submitted run. Resource-carry hooks need to + # hand off mutexes/leases even when an attempt fails during submit + # before a run id is available. + previous_context = context + try: + with self.hooks.around_run(context): + try: + recovered_response = None + if reused_after_submit_failure: + recovered_response = self._recover_submitted_run_after_submit_error( + submission_id=self._submission_id_from_body(body), + ) + if recovered_response is not None: + response = self._adopt_submitted_run( + response=recovered_response, + body=body, + pipeline_path=pipeline_path, + attempt=attempt, + context=context, + ) + if attempt > 1: + self.hooks.after_retry_submit(context) + else: + try: + response = self.submit_prepared_body( + body, + pipeline_path=pipeline_path, + attempt=attempt, + context=context, + notify_submit_error=False, + ) + except Exception as submit_exc: + if context.run_id is not None: + raise + submission_id_for_recovery = self._submission_id_from_body(body) + self.logger.warn( + "Submit failed before a run id was returned " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id_for_recovery}): " + f"{submit_exc}. Checking whether the run was actually created." + ) + recovered_response = self._recover_submitted_run_after_submit_error( + submission_id=submission_id_for_recovery, + ) + if recovered_response is None: + self.hooks.on_submit_error(submit_exc, context=context) + raise + response = self._adopt_submitted_run( + response=recovered_response, + body=body, + pipeline_path=pipeline_path, + attempt=attempt, + context=context, + ) + if attempt > 1: + self.hooks.after_retry_submit(context) + result: dict[str, Any] + if wait and context.run_id: + wait_result = self.wait_for_completion( + context.run_id, + max_wait=max_wait, + poll_interval=poll_interval, + use_graph_state=use_graph_state, + context=context, + allow_zero_poll_interval=allow_zero_poll_interval, + timeout_clock=timeout_clock, + exit_on_first_failure=exit_on_first_failure, + ) + result = {"response": response, "wait": wait_result} + else: + result = {"response": response} + result["context"] = context + result["attempts"] = attempts + success = True + return result + except Exception as exc: + error = exc + last_error = exc + if isinstance(exc, AmbiguousPipelineRunRecoveryError): + self.hooks.on_fail_fast_before_release(context, exc) + raise + if ( + context.run_id + and attempt < max_attempts + and self.hooks.should_cancel_previous_run( + context, + exc, + next_attempt=attempt + 1, + ) + ): + self.cancel_run(context.run_id) + if attempt >= max_attempts: + self.hooks.on_fail_fast_before_release(context, exc) + raise + retry_context = context if context.run_id else previous_context or context + self.hooks.before_retry(retry_context, exc, next_attempt=attempt + 1) + retry_requested = True + finally: + if lifecycle_started: + self.hooks.after_run_lifecycle(context, success=success, error=error) + if retry_requested: + continue + if last_error is not None: # pragma: no cover - defensive + raise last_error + raise PipelineRunError("Pipeline run did not start") # pragma: no cover + + def run_prepared_body( + self, + body: dict[str, Any], + *, + pipeline_path: str | Path | None = None, + wait: bool = False, + max_wait: float | None = 600.0, + poll_interval: float = 10.0, + use_graph_state: bool = False, + max_attempts: int = 1, + retry_body_factory: Callable[ + [int, PipelineRunContext | None, Exception | None], dict[str, Any] + ] | None = None, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", + exit_on_first_failure: bool = False, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Submit/wait/retry an already prepared submit body. + + ``retry_body_factory`` lets downstreams refresh retry bodies while still + keeping hydration/layout/validation outside the generic lifecycle. + """ + + def body_factory( + attempt: int, + previous_context: PipelineRunContext | None, + error: Exception | None, + ) -> dict[str, Any]: + if attempt > 1 and retry_body_factory is not None: + return retry_body_factory(attempt, previous_context, error) + return copy.deepcopy(body) + + return self._run_body_factory( + body_factory, + pipeline_path=pipeline_path, + wait=wait, + max_wait=max_wait, + poll_interval=poll_interval, + use_graph_state=use_graph_state, + max_attempts=max_attempts, + allow_zero_poll_interval=allow_zero_poll_interval, + timeout_clock=timeout_clock, + exit_on_first_failure=exit_on_first_failure, + metadata=metadata, + ) + + def run_pipeline_spec( + self, + pipeline_spec: dict[str, Any], + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + pipeline_path: str | Path | None = None, + run_as: str | None = None, + hydrate: bool = True, + wait: bool = False, + max_wait: float | None = 600.0, + poll_interval: float = 10.0, + use_graph_state: bool = False, + max_attempts: int = 1, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", + exit_on_first_failure: bool = False, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Submit/wait/retry an already hydrated/validated in-memory spec.""" + + def body_factory( + _attempt: int, + _previous_context: PipelineRunContext | None, + _error: Exception | None, + ) -> dict[str, Any]: + return self.prepare_submit_payload_from_spec( + copy.deepcopy(pipeline_spec), + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=hydrate, + ).to_body() + + return self._run_body_factory( + body_factory, + pipeline_path=pipeline_path, + wait=wait, + max_wait=max_wait, + poll_interval=poll_interval, + use_graph_state=use_graph_state, + max_attempts=max_attempts, + allow_zero_poll_interval=allow_zero_poll_interval, + timeout_clock=timeout_clock, + exit_on_first_failure=exit_on_first_failure, + metadata=metadata, + ) + + def run_pipeline( + self, + pipeline_path: str | Path, + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + hydrate: bool = True, + run_as: str | None = None, + resolution_overrides: dict[str, Any] | None = None, + wait: bool = False, + max_wait: float | None = 600.0, + poll_interval: float = 10.0, + use_graph_state: bool = False, + max_attempts: int = 1, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", + exit_on_first_failure: bool = False, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Submit (and optionally wait for) a pipeline with lifecycle hooks. + + Unlike ``run_pipeline_spec``, path-based runs intentionally rebuild the + submit body on every retry so read/hydrate/resolution hooks are + re-invoked for each attempt. + """ + + def body_factory( + _attempt: int, + _previous_context: PipelineRunContext | None, + _error: Exception | None, + ) -> dict[str, Any]: + return self.prepare_submit_payload( + pipeline_path, + run_args=run_args, + annotations=annotations, + hydrate=hydrate, + run_as=run_as, + resolution_overrides=resolution_overrides, + ).to_body() + + return self._run_body_factory( + body_factory, + pipeline_path=pipeline_path, + wait=wait, + max_wait=max_wait, + poll_interval=poll_interval, + use_graph_state=use_graph_state, + max_attempts=max_attempts, + allow_zero_poll_interval=allow_zero_poll_interval, + timeout_clock=timeout_clock, + exit_on_first_failure=exit_on_first_failure, + metadata=metadata, + ) + + +def parse_key_value_entries(entries: list[str] | None) -> dict[str, str]: + parsed: dict[str, str] = {} + for entry in entries or []: + if "=" not in entry: + raise PipelineRunError("Expected KEY=VALUE") + key, value = entry.split("=", 1) + if not key: + raise PipelineRunError("Expected KEY=VALUE") + parsed[key] = value + return parsed + + +def parse_json_or_key_values( + text: str | Mapping[str, Any] | None, + entries: list[str] | None = None, +) -> dict[str, Any]: + result: dict[str, Any] = {} + if text: + loaded = dict(text) if isinstance(text, Mapping) else json.loads(text) + if not isinstance(loaded, dict): + raise PipelineRunError("JSON value must be an object") + result.update(loaded) + result.update(parse_key_value_entries(entries)) + return result diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py new file mode 100644 index 0000000..c87a288 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -0,0 +1,712 @@ +"""Rich pipeline-run search/filter helpers. + +This module is native-free and API-client agnostic. It builds Tangle search +``filter_query`` payloads, resolves ``created_by=me`` via ``users_me()``, and +formats results for CLI/MCP consumers. Downstreams such as tangle-deploy can +subclass ``PipelineRunSearch`` with provider-authenticated client creation. +""" + +from __future__ import annotations + +import json +import re +import urllib.parse +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from .handler import TangleCliHandler +from .logger import Logger + +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_MAX_PIPELINE_NAME_WIDTH = 50 +_IDX_WIDTH = 3 +_CREATED_AT_WIDTH = 16 + + +@dataclass +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. + """ + + rows: list[dict[str, Any]] + page_token: str | None + next_page_token: str | None + ui_filter_url: str + next_ui_filter_url: str | None + + +class PipelineRunSearch(TangleCliHandler): + """Resource manager for pipeline-run search/filter behavior. + + The class is intentionally native-free. Downstream packages can inject an + authenticated client or lazy ``client_factory`` and subclass the formatting + or predicate builders. + """ + + def __init__( + self, + client: Any = None, + *, + client_factory: Any | None = None, + logger: Logger | None = None, + **kwargs: Any, + ) -> None: + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) + self.logger = self.log + + @staticmethod + def build_predicate(*, predicate_type: str, **fields: Any) -> dict[str, Any]: + return build_predicate(predicate_type=predicate_type, **fields) + + @staticmethod + def build_value_contains(*, key: str, value_substring: str) -> dict[str, Any]: + return build_value_contains(key=key, value_substring=value_substring) + + @staticmethod + def build_value_equals(*, key: str, value: str) -> dict[str, Any]: + return build_value_equals(key=key, value=value) + + @staticmethod + def build_key_exists(*, key: str) -> dict[str, Any]: + return build_key_exists(key=key) + + @staticmethod + def build_time_range( + *, + key: str, + start_time: str | None = None, + end_time: str | None = None, + ) -> dict[str, Any]: + return build_time_range(key=key, start_time=start_time, end_time=end_time) + + def validate_created_by(self, *, value: str) -> str: + return validate_created_by(value=value, logger=self.logger) + + @staticmethod + def parse_annotation(text: str) -> tuple[str, str | None]: + return parse_annotation(text) + + @staticmethod + def normalize_query_input(text: str) -> dict[str, Any]: + return normalize_query_input(text) + + @staticmethod + def build_ui_filter_url( + *, + base_url: str, + name: str | None = None, + created_by: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + page_token: str | None = None, + ) -> str: + return build_ui_filter_url( + base_url=base_url, + name=name, + created_by=created_by, + start_date=start_date, + end_date=end_date, + page_token=page_token, + ) + + @staticmethod + def build_filter_query( + *, + name: str | None = None, + created_by: str | None = None, + annotations: dict[str, str | None] | None = None, + start_date: str | None = None, + end_date: str | None = None, + ) -> dict[str, Any] | None: + return build_filter_query( + name=name, + created_by=created_by, + annotations=annotations, + start_date=start_date, + end_date=end_date, + ) + + def resolve_created_by(self, *, created_by: str | None) -> tuple[str | None, dict[str, Any] | None]: + return resolve_created_by(created_by=created_by, client=self._require_client(), logger=self.logger) + + def resolve_dates( + self, + *, + start_date: str | None, + end_date: str | None, + local_time: bool, + ) -> tuple[str | None, str | None]: + return resolve_dates(start_date=start_date, end_date=end_date, local_time=local_time, logger=self.logger) + + @staticmethod + def format_mcp_table(*, rows: list[dict[str, Any]], next_page_token: str | None, ui_filter_url: str) -> str: + return _format_mcp_table(rows=rows, next_page_token=next_page_token, ui_filter_url=ui_filter_url) + + @staticmethod + def format_cli_table(*, page_chunks: list[PageChunk], total_count: int) -> str: + return _format_cli_table(page_chunks=page_chunks, total_count=total_count) + + def fetch_pages( + self, + *, + filter_query_str: str | None, + limit: int, + page_token: str | None, + base_url: str, + name: str | None, + created_by: str | None, + start_date: str | None, + end_date: str | None, + ) -> tuple[list[dict[str, Any]], list[PageChunk], str | None]: + return fetch_pipeline_run_search_pages( + client=self._require_client(), + filter_query_str=filter_query_str, + limit=limit, + page_token=page_token, + base_url=base_url, + name=name, + created_by=created_by, + start_date=start_date, + end_date=end_date, + ) + + @staticmethod + def build_result( + *, + all_rows: list[dict[str, Any]], + page_chunks: list[PageChunk], + final_next_token: str | None, + first_ui_url: str, + ) -> dict[str, Any]: + return build_pipeline_run_search_result( + all_rows=all_rows, + page_chunks=page_chunks, + final_next_token=final_next_token, + first_ui_url=first_ui_url, + ) + + def search( + self, + *, + name: str | None = None, + created_by: str | None = None, + annotations: dict[str, str | None] | None = None, + start_date: str | None = None, + end_date: str | None = None, + local_time: bool = False, + query: dict[str, Any] | None = None, + limit: int = 10, + page_token: str | None = None, + ) -> dict[str, Any]: + """Search pipeline runs and return rows, page metadata, and tables.""" + + limit = max(1, min(limit, 100)) + resolved_created_by, err = self.resolve_created_by(created_by=created_by) + if err is not None: + return err + resolved_start, resolved_end = self.resolve_dates( + start_date=start_date, + end_date=end_date, + local_time=local_time, + ) + filter_query_dict = query or self.build_filter_query( + name=name, + created_by=resolved_created_by, + annotations=annotations, + start_date=resolved_start, + end_date=resolved_end, + ) + filter_query_str = json.dumps(filter_query_dict, separators=(",", ":")) if filter_query_dict else None + self.logger.info(f"Searching pipeline runs (limit={limit})...") + base_url = getattr(self._require_client(), "base_url", "").rstrip("/") + all_rows, page_chunks, final_next_token = self.fetch_pages( + filter_query_str=filter_query_str, + limit=limit, + page_token=page_token, + base_url=base_url, + name=name, + created_by=resolved_created_by, + start_date=resolved_start, + end_date=resolved_end, + ) + if len(page_chunks) > 1: + self.logger.info(f"Fetched {len(page_chunks)} pages to collect {len(all_rows)} results.") + first_ui_url = ( + page_chunks[0].ui_filter_url + if page_chunks + else self.build_ui_filter_url( + base_url=base_url, + name=name, + created_by=resolved_created_by, + start_date=resolved_start, + end_date=resolved_end, + page_token=page_token, + ) + ) + return self.build_result( + all_rows=all_rows, + page_chunks=page_chunks, + final_next_token=final_next_token, + first_ui_url=first_ui_url, + ) + + +def build_predicate(*, predicate_type: str, **fields: Any) -> dict[str, Any]: + schemas: dict[str, tuple[str, ...]] = { + "value_contains": ("key", "value_substring"), + "value_equals": ("key", "value"), + "key_exists": ("key",), + "time_range": ("key", "start_time", "end_time"), + } + schema = schemas.get(predicate_type) + if schema is None: + raise ValueError(f"Unknown predicate type: {predicate_type!r}") + return {predicate_type: {key: fields[key] for key in schema if key in fields}} + + +def build_value_contains(*, key: str, value_substring: str) -> dict[str, Any]: + return build_predicate(predicate_type="value_contains", key=key, value_substring=value_substring) + + +def build_value_equals(*, key: str, value: str) -> dict[str, Any]: + return build_predicate(predicate_type="value_equals", key=key, value=value) + + +def build_key_exists(*, key: str) -> dict[str, Any]: + return build_predicate(predicate_type="key_exists", key=key) + + +def build_time_range( + *, + key: str, + start_time: str | None = None, + end_time: str | None = None, +) -> dict[str, Any]: + fields: dict[str, Any] = {"key": key} + if start_time is not None: + fields["start_time"] = start_time + if end_time is not None: + fields["end_time"] = end_time + return build_predicate(predicate_type="time_range", **fields) + + +def validate_created_by(*, value: str, logger: Logger) -> str: + """Warn (but do not reject) if *value* is not ``me`` and not an email.""" + + if value != "me" and not _EMAIL_RE.match(value): + logger.warn( + f"⚠️ created_by '{value}' does not look like a valid email" + " — results may be empty or the API may return an error." + ) + return value + + +def has_timezone(*, value: str) -> bool: + """Return True if *value* already includes a timezone offset or ``Z``.""" + + return value.endswith("Z") or "+" in value or value.count("-") >= 3 + + +def apply_local_timezone(*, value: str, logger: Logger, suppress_log: bool = False) -> str: + """Append the system's local UTC offset to a naive datetime string.""" + + now = datetime.now(tz=timezone.utc).astimezone() + tz_name = now.tzname() or "UTC" + offset_str = now.strftime("%z") + offset_formatted = f"{offset_str[:3]}:{offset_str[3:]}" + + try: + import time as _time + + iana_name = _time.tzname[0] if _time.daylight == 0 else _time.tzname[1] + except Exception: + iana_name = tz_name + + if not suppress_log: + logger.info("") + logger.info(f"🕐 Timezone: {iana_name} (UTC{offset_formatted})") + logger.info(f" Dates will be interpreted as {iana_name} time.") + logger.info("") + return f"{value}{offset_formatted}" + + +def parse_annotation(text: str) -> tuple[str, str | None]: + """Parse ``key=value`` or ``key`` annotation filters.""" + + if "=" in text: + key, value = text.split("=", 1) + return key, value + return text, None + + +def normalize_query_input(text: str) -> dict[str, Any]: + """Parse raw ``--query`` input, auto-detecting URL-encoding.""" + + try: + loaded = json.loads(text) + except (json.JSONDecodeError, ValueError): + loaded = None + if isinstance(loaded, dict): + return loaded + + try: + decoded = urllib.parse.unquote(text) + loaded = json.loads(decoded) + except (json.JSONDecodeError, ValueError) as exc: + raise ValueError( + "Invalid --query input: not valid JSON (plain or URL-encoded). " + f"Parse error: {exc}" + ) from exc + if not isinstance(loaded, dict): + raise ValueError("Invalid --query input: JSON value must be an object") + return loaded + + +def build_ui_filter_url( + *, + base_url: str, + name: str | None = None, + created_by: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + page_token: str | None = None, +) -> str: + """Build a Tangle UI URL with a friendly ``?filter=`` query parameter.""" + + filter_obj: dict[str, str] = {} + if name: + filter_obj["pipeline_name"] = name + if created_by: + filter_obj["created_by"] = created_by + if start_date: + filter_obj["created_after"] = start_date + if end_date: + filter_obj["created_before"] = end_date + if not filter_obj: + return base_url + params: dict[str, str] = {"filter": json.dumps(filter_obj)} + if page_token: + params["page_token"] = page_token + return f"{base_url}/?{urllib.parse.urlencode(params)}" + + +def build_filter_query( + *, + name: str | None = None, + created_by: str | None = None, + annotations: dict[str, str | None] | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> dict[str, Any] | None: + """Translate friendly search params into a Tangle ``filter_query`` object.""" + + predicates: list[dict[str, Any]] = [] + if name: + predicates.append(build_value_contains(key="system/pipeline_run.name", value_substring=name)) + if created_by: + predicates.append(build_value_equals(key="system/pipeline_run.created_by", value=created_by)) + if annotations: + for key, value in annotations.items(): + if value is None: + predicates.append(build_key_exists(key=key)) + elif value == "": + predicates.append(build_value_equals(key=key, value="")) + else: + predicates.append(build_value_contains(key=key, value_substring=value)) + if start_date or end_date: + predicates.append( + build_time_range( + key="system/pipeline_run.date.created_at", + start_time=start_date, + end_time=end_date, + ) + ) + return {"and": predicates} if predicates else None + + +def resolve_created_by( + *, + created_by: str | None, + client: Any, + logger: Logger, +) -> tuple[str | None, dict[str, Any] | None]: + """Resolve ``created_by=me`` to the current user's id/email if requested.""" + + if not created_by: + return created_by, None + resolved = created_by + if created_by.lower() == "me": + user_info = client.users_me() + if user_info: + resolved = str(getattr(user_info, "id", None) or user_info.get("id")) + logger.info(f"Resolved 'me' to: {resolved}") + else: + return None, {"error": "Could not resolve 'me': authentication failed or user not found."} + validate_created_by(value=resolved, logger=logger) + return resolved, None + + +def resolve_dates( + *, + start_date: str | None, + end_date: str | None, + local_time: bool, + logger: Logger, +) -> tuple[str | None, str | None]: + """Apply local timezone to naive datetimes.""" + + resolved_start = start_date + resolved_end = end_date + tz_logged = False + for label, date_val, attr in ( + ("start-date", resolved_start, "start"), + ("end-date", resolved_end, "end"), + ): + if not date_val: + continue + if not has_timezone(value=date_val): + if not local_time: + logger.warn( + f"⚠️ --{label} '{date_val}' has no timezone — assuming local time." + " Pass an explicit timezone (e.g. 'Z' or '+00:00')" + " or --local-time to silence this warning." + ) + date_val = apply_local_timezone(value=date_val, logger=logger, suppress_log=tz_logged) + tz_logged = True + if attr == "start": + resolved_start = date_val + else: + resolved_end = date_val + return resolved_start, resolved_end + + +def _format_datetime(value: str) -> str: + if not value: + return "" + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError): + return value[:16] if len(value) > 16 else value + + +def _format_mcp_table(*, rows: list[dict[str, Any]], next_page_token: str | None, ui_filter_url: str) -> str: + if not rows: + return "No pipeline runs found matching the search criteria." + lines = [ + f"| {'#':>3} | Run ID | Pipeline Name | Created By | Created At (UTC) |", + "|-----|--------|--------------|------------|------------|", + ] + for row in rows: + run_id = row["run_id"] + short_id = f"{run_id[:7]}...{run_id[-3:]}" if len(run_id) > 12 else run_id + created_at = _format_datetime(row["created_at"]) + lines.append( + f"| {row['index']:>3} | [{short_id}]({row['run_url']}) | " + f"{row['pipeline_name']} | {row['created_by']} | {created_at} |" + ) + lines.append("") + lines.append(f"Showing {len(rows)} results.") + if ui_filter_url: + lines.append(f"[View filtered results in Tangle UI]({ui_filter_url})") + if next_page_token: + lines.append(f"Next page token: `{next_page_token}`") + return "\n".join(lines) + + +def _truncate(text: str, width: int) -> str: + return text if len(text) <= width else text[: width - 1] + "…" + + +def _compute_column_widths(all_rows: list[dict[str, Any]]) -> tuple[int, int, int]: + name_w = min( + max((len(r["pipeline_name"]) for r in all_rows), default=_MAX_PIPELINE_NAME_WIDTH), + _MAX_PIPELINE_NAME_WIDTH, + ) + name_w = max(name_w, len("Pipeline Name")) + email_w = max((len(r["created_by"]) for r in all_rows), default=len("Created By")) + email_w = max(email_w, len("Created By")) + url_w = max((len(r["run_url"]) for r in all_rows), default=len("Tangle Link")) + url_w = max(url_w, len("Tangle Link")) + return name_w, email_w, url_w + + +def _cli_header_and_sep(*, name_w: int, email_w: int, url_w: int) -> tuple[str, str]: + hdr = ( + f"| {'#':>{_IDX_WIDTH}} " + f"| {'Pipeline Name':<{name_w}} " + f"| {'Created By':<{email_w}} " + f"| {'Created At (UTC)':<{_CREATED_AT_WIDTH}} " + f"| {'Tangle Link':<{url_w}} |" + ) + sep = ( + f"|{'─' * (_IDX_WIDTH + 2)}" + f"|{'─' * (name_w + 2)}" + f"|{'─' * (email_w + 2)}" + f"|{'─' * (_CREATED_AT_WIDTH + 2)}" + f"|{'─' * (url_w + 2)}|" + ) + return hdr, sep + + +def _format_cli_table(*, page_chunks: list[PageChunk], total_count: int) -> str: + if not page_chunks or total_count == 0: + return "\n🔍 No pipeline runs found matching the search criteria.\n" + all_rows = [row for chunk in page_chunks for row in chunk.rows] + name_w, email_w, url_w = _compute_column_widths(all_rows) + hdr, sep = _cli_header_and_sep(name_w=name_w, email_w=email_w, url_w=url_w) + lines: list[str] = ["", "🔍 Pipeline Run Search Results", "─" * len(sep)] + for chunk_idx, chunk in enumerate(page_chunks): + page_num = chunk_idx + 1 + first_idx = chunk.rows[0]["index"] + last_idx = chunk.rows[-1]["index"] + lines.append("") + if len(page_chunks) > 1: + lines.append(f"📄 Page {page_num} (rows {first_idx}–{last_idx})") + lines.append("") + lines.append(hdr) + lines.append(sep) + for row in chunk.rows: + name_val = _truncate(row["pipeline_name"], name_w) + created_at = _format_datetime(row["created_at"]) + lines.append( + f"| {row['index']:>{_IDX_WIDTH}} " + f"| {name_val:<{name_w}} " + f"| {row['created_by']:<{email_w}} " + f"| {created_at:<{_CREATED_AT_WIDTH}} " + f"| {row['run_url']:<{url_w}} |" + ) + lines.append(sep) + footer_label = f"Page {page_num} · Rows {first_idx}–{last_idx} of {total_count}" + lines.extend(["", f" ── {footer_label} ──", ""]) + if chunk.ui_filter_url: + lines.extend([" 🔗 View this page in UI:", f" {chunk.ui_filter_url}", ""]) + if chunk.next_page_token: + lines.extend([" 📄 Page token:", f" {chunk.next_page_token}", ""]) + if chunk.next_ui_filter_url: + lines.extend([" ➡️ Next page in UI:", f" {chunk.next_ui_filter_url}", ""]) + lines.append(f" {'─' * (len(footer_label) + 6)}") + lines.append("") + lines.append("─" * len(sep)) + lines.append(f"✅ Total: {total_count} results across {len(page_chunks)} page(s).") + lines.append("") + return "\n".join(lines) + + +def fetch_pipeline_run_search_pages( + *, + client: Any, + filter_query_str: str | None, + limit: int, + page_token: str | None, + base_url: str, + name: str | None, + created_by: str | None, + start_date: str | None, + end_date: str | None, +) -> tuple[list[dict[str, Any]], list[PageChunk], str | None]: + """Paginate through the API collecting up to ``limit`` rows.""" + + all_rows: list[dict[str, Any]] = [] + page_chunks: list[PageChunk] = [] + current_token = page_token + running_index = 0 + while running_index < limit: + response = client.pipeline_runs_list( + filter_query=filter_query_str, + page_token=current_token, + include_pipeline_names=True, + ) + page_runs = response.get("pipeline_runs", []) + next_token = response.get("next_page_token") + if not page_runs: + break + page_runs = page_runs[: limit - running_index] + chunk_rows: list[dict[str, Any]] = [] + for run in page_runs: + running_index += 1 + run_id = run.get("id", "") + chunk_rows.append( + { + "index": running_index, + "run_id": run_id, + "pipeline_name": run.get("pipeline_name", ""), + "created_by": run.get("created_by", ""), + "created_at": run.get("created_at", ""), + "run_url": f"{base_url}/runs/{run_id}", + } + ) + ui_url_for_page = build_ui_filter_url( + base_url=base_url, + name=name, + created_by=created_by, + start_date=start_date, + end_date=end_date, + page_token=current_token, + ) + next_ui_url = ( + build_ui_filter_url( + base_url=base_url, + name=name, + created_by=created_by, + start_date=start_date, + end_date=end_date, + page_token=next_token, + ) + if next_token + else None + ) + page_chunks.append( + PageChunk( + rows=chunk_rows, + page_token=current_token, + next_page_token=next_token, + ui_filter_url=ui_url_for_page, + next_ui_filter_url=next_ui_url, + ) + ) + all_rows.extend(chunk_rows) + current_token = next_token + if not current_token: + break + final_next_token = current_token if running_index >= limit and current_token else None + return all_rows, page_chunks, final_next_token + + +def build_pipeline_run_search_result( + *, + all_rows: list[dict[str, Any]], + page_chunks: list[PageChunk], + final_next_token: str | None, + first_ui_url: str, +) -> dict[str, Any]: + pages_meta: list[dict[str, Any]] = [] + for idx, chunk in enumerate(page_chunks): + pages_meta.append( + { + "page": idx + 1, + "rows": f"{chunk.rows[0]['index']}–{chunk.rows[-1]['index']}", + "ui_url": chunk.ui_filter_url, + "page_token": chunk.page_token, + "next_page_token": chunk.next_page_token, + "next_ui_url": chunk.next_ui_filter_url, + } + ) + return { + "runs": all_rows, + "count": len(all_rows), + "pages": pages_meta, + "markdown_table": _format_mcp_table( + rows=all_rows, + next_page_token=final_next_token, + ui_filter_url=first_ui_url, + ), + "cli_table": _format_cli_table(page_chunks=page_chunks, total_count=len(all_rows)), + "next_page_token": final_next_token, + "ui_filter_url": first_ui_url, + } diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py new file mode 100644 index 0000000..2b50d39 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -0,0 +1,620 @@ +"""High-level OSS pipeline-run orchestration. + +This module owns the generic path-based run flow that downstream CLIs can share: +load/hydrate a pipeline, perform generic pre-submit preparation, optionally +layout/validate, then submit/wait/retry through :mod:`tangle_cli.pipeline_run_manager`. +Downstream-specific behavior (provider auth, cloud-object I/O, hosted logs, +notifications, mutexes, schedulers, service-account annotations, and legacy +result shapes) is exposed as hooks rather than imported here. +""" + +from __future__ import annotations + +import copy +import inspect +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping + +from .pipeline_run_manager import ( + PipelineRunContext, + PipelineRunError, + PipelineRunHooks, + PipelineRunManager, + PipelineSubmitPayload, + PipelineWaitOutcome, +) + + +@dataclass(frozen=True) +class PipelinePreparationResult: + """Prepared pipeline state before submit/wait orchestration.""" + + pipeline_spec: dict[str, Any] + pipeline_name: str + effective_path: str | Path | None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PipelineRunnerHooks(PipelineRunHooks): + """Extension seams for high-level pipeline-run orchestration. + + ``PipelineRunHooks`` already covers submit/wait/retry lifecycle behavior. + This subclass adds path/spec preparation seams so downstreams can keep their + platform-specific behavior outside the generic OSS runner. + """ + + def initial_pipeline_name(self, pipeline_path: str | Path) -> str: + """Return the fallback display/run name before the spec is loaded.""" + + return Path(str(pipeline_path)).stem + + def load_pipeline(self, pipeline_path: str | Path) -> dict[str, Any]: + """Load an unhydrated pipeline spec. + + The default delegates to ``read_pipeline_yaml`` from ``PipelineRunHooks``. + Downstreams can override this for alternate URI schemes such as gs://. + """ + + return self.read_pipeline_yaml(pipeline_path) + + def hydrate_pipeline_for_run( + self, + pipeline_path: str | Path, + *, + client: Any | None = None, + resolution_overrides: dict[str, Any] | None = None, + ) -> tuple[dict[str, Any], str | Path | None]: + """Hydrate a pipeline path for a run. + + Returns the hydrated spec and an optional effective path. The effective + path is the location layout/validation should use when hydration writes + to a temporary file. OSS hydration is in-memory by default. + """ + + hydrate_kwargs: dict[str, Any] = {"resolution_overrides": resolution_overrides} + try: + parameters = inspect.signature(self.hydrate_pipeline).parameters + except (TypeError, ValueError): + parameters = {} + if client is not None and ( + "client" in parameters + or any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()) + ): + hydrate_kwargs["client"] = client + + return ( + self.hydrate_pipeline( + pipeline_path, + **hydrate_kwargs, + ), + None, + ) + + def prepare_loaded_pipeline_spec( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path, + effective_path: str | Path | None, + hydrate: bool, + run_args: dict[str, Any] | None, + ) -> dict[str, Any]: + """Transform a loaded/hydrated spec before validation/layout. + + Use this for downstream template post-processing that is not specific to + submit payload construction. + """ + + return pipeline_spec + + def validate_pipeline_for_run( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path, + effective_path: str | Path | None, + skip_validation: bool, + ) -> list[str]: + """Return validation errors for a prepared pipeline spec. + + The OSS default intentionally does not enforce the local authoring + validator here: submit-time API validation remains the source of truth, + while downstreams can plug in stricter schema/input validators. + """ + + del pipeline_spec, pipeline_path, effective_path, skip_validation + return [] + + def has_layout(self, pipeline_spec: Mapping[str, Any]) -> bool: + """Return True when a pipeline graph already has non-zero coordinates.""" + + tasks = ( + pipeline_spec.get("implementation", {}) + .get("graph", {}) + .get("tasks", {}) + ) + if not tasks: + return True + + for task in tasks.values() if isinstance(tasks, Mapping) else []: + if not isinstance(task, Mapping): + continue + annotations = task.get("annotations", {}) + position = annotations.get("editor.position") if isinstance(annotations, Mapping) else None + if isinstance(position, str): + try: + import json + + parsed = json.loads(position) + except (TypeError, ValueError): + parsed = None + if isinstance(parsed, Mapping) and (parsed.get("x", 0) != 0 or parsed.get("y", 0) != 0): + return True + component_ref = task.get("componentRef", {}) + nested_spec = component_ref.get("spec") if isinstance(component_ref, Mapping) else None + if isinstance(nested_spec, Mapping) and not self.has_layout(nested_spec): + return False + + return False + + def should_apply_layout( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path, + effective_path: str | Path | None, + skip_layout: bool, + force_layout: bool, + layout_algorithm: str | None, + ) -> bool: + """Return True when the runner should layout before submit.""" + + del pipeline_path, effective_path, layout_algorithm + return not skip_layout and (force_layout or not self.has_layout(pipeline_spec)) + + def apply_layout( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path, + effective_path: str | Path | None, + force_layout: bool, + layout_algorithm: str | None, + ) -> dict[str, Any]: + """Apply the OSS deterministic layout to an in-memory pipeline spec.""" + + del pipeline_path, effective_path, force_layout, layout_algorithm + from .pipelines import layout_pipeline_spec + + laid_out = copy.deepcopy(pipeline_spec) + layout_pipeline_spec(laid_out, recursive=True) + return laid_out + + def before_submit_pipeline_spec( + self, + pipeline_spec: dict[str, Any], + *, + pipeline_path: str | Path, + effective_path: str | Path | None, + run_args: dict[str, Any] | None, + ) -> dict[str, Any]: + """Final pre-submit transform after validation/layout.""" + + del pipeline_path, effective_path, run_args + return pipeline_spec + + def metadata_for_run( + self, + *, + pipeline_name: str, + pipeline_path: str | Path, + effective_path: str | Path | None, + wait: bool, + open_browser: bool, + include_next_steps: bool, + retry: int, + max_wait: float | None, + poll_interval: float, + extra_metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Build metadata passed to submit/wait/retry lifecycle hooks.""" + + metadata: dict[str, Any] = { + "pipeline_name": pipeline_name, + "pipeline_path": str(pipeline_path), + "wait": wait, + "open_browser": open_browser, + "include_next_steps": include_next_steps, + "retry": retry, + "max_attempts": retry + 1 if wait else 1, + "poll_interval": poll_interval, + "max_wait_time": max_wait, + } + if effective_path is not None: + metadata["effective_path"] = str(effective_path) + if extra_metadata: + metadata.update(extra_metadata) + return metadata + + def cleanup_prepared_pipeline( + self, + preparation: PipelinePreparationResult, + *, + error: Exception | None = None, + ) -> None: + """Clean up resources associated with a prepared pipeline. + + Downstreams that hydrate into temporary files can override this to + remove ``preparation.effective_path`` on success, validation failure, + submit failure, wait failure, or retry failure. + """ + + del preparation, error + + def format_run_result( + self, + result: dict[str, Any], + *, + preparation: PipelinePreparationResult, + ) -> dict[str, Any]: + """Return the normalized OSS orchestration result. + + Downstreams can override this to preserve legacy CLI/MCP return shapes. + """ + + context = result.get("context") + response = result.get("response") if isinstance(result.get("response"), Mapping) else {} + wait_result = result.get("wait") if isinstance(result.get("wait"), Mapping) else None + run_id = getattr(context, "run_id", None) if isinstance(context, PipelineRunContext) else response.get("id") + root_execution_id = ( + getattr(context, "root_execution_id", None) + if isinstance(context, PipelineRunContext) + else response.get("root_execution_id") + ) + status = "submitted" + success: bool | None = True + if wait_result is not None: + status = str(wait_result.get("status") or "unknown") + outcome = ( + context.wait_outcome + if isinstance(context, PipelineRunContext) and context.wait_outcome is not None + else PipelineWaitOutcome.from_wait_result(wait_result) + ) + success = outcome.success + result_pipeline_name = ( + str(context.run_name) + if isinstance(context, PipelineRunContext) and context.run_name + else preparation.pipeline_name + ) + return { + **result, + "success": success, + "status": status, + "pipeline_name": result_pipeline_name, + "run_id": run_id, + "root_execution_id": root_execution_id, + "preparation": preparation, + } + + +@dataclass +class PipelineRunner(PipelineRunnerHooks, PipelineRunManager): + """Generic high-level pipeline runner orchestration.""" + + hooks: PipelineRunnerHooks = field(default_factory=PipelineRunnerHooks) + + def __post_init__(self) -> None: + super().__post_init__() + if self.hooks is not self: + setattr(self.hooks, "client", self.client) + + @staticmethod + def _ensure_mapping(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise PipelineRunError("pipeline spec must be a mapping") + return value + + @staticmethod + def _accepts_client_keyword(method: Any) -> bool: + try: + parameters = inspect.signature(method).parameters + except (TypeError, ValueError): + return False + return "client" in parameters or any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in parameters.values() + ) + + def _high_level_hooks(self) -> PipelineRunnerHooks: + """Return the object that owns high-level path/spec hooks. + + Subclasses override methods on ``self``. For direct OSS composition, + preserve the existing ``PipelineRunner(client, hooks=...)`` API. + """ + + if type(self) is PipelineRunner and self.hooks is not self: + return self.hooks + return self + + def prepare_pipeline_for_run( + self, + pipeline_path: str | Path, + *, + run_args: dict[str, Any] | None = None, + hydrate: bool = True, + resolution_overrides: dict[str, Any] | None = None, + skip_validation: bool = False, + skip_layout: bool = True, + force_layout: bool = False, + layout_algorithm: str | None = None, + ) -> PipelinePreparationResult: + """Load/hydrate/validate/layout a pipeline before submission.""" + + hooks = self._high_level_hooks() + pipeline_name = hooks.initial_pipeline_name(pipeline_path) + effective_path: str | Path | None = pipeline_path + pipeline_spec: Any = {} + preparation: PipelinePreparationResult | None = None + try: + if hydrate: + hydrate_pipeline_for_run = hooks.hydrate_pipeline_for_run + hydrate_kwargs: dict[str, Any] = {"resolution_overrides": resolution_overrides} + if self._accepts_client_keyword(hydrate_pipeline_for_run): + hydrate_kwargs["client"] = self._get_client() + pipeline_spec, hydrated_effective_path = hydrate_pipeline_for_run( + pipeline_path, + **hydrate_kwargs, + ) + if hydrated_effective_path is not None: + effective_path = hydrated_effective_path + else: + pipeline_spec = hooks.load_pipeline(pipeline_path) + + pipeline_spec = self._ensure_mapping(pipeline_spec) + spec_name = pipeline_spec.get("name") + if isinstance(spec_name, str) and spec_name: + pipeline_name = spec_name + + pipeline_spec = hooks.prepare_loaded_pipeline_spec( + pipeline_spec, + pipeline_path=pipeline_path, + effective_path=effective_path, + hydrate=hydrate, + run_args=run_args, + ) + pipeline_spec = self._ensure_mapping(pipeline_spec) + spec_name = pipeline_spec.get("name") + if isinstance(spec_name, str) and spec_name: + pipeline_name = spec_name + + if hooks.should_apply_layout( + pipeline_spec, + pipeline_path=pipeline_path, + effective_path=effective_path, + skip_layout=skip_layout, + force_layout=force_layout, + layout_algorithm=layout_algorithm, + ): + pipeline_spec = hooks.apply_layout( + pipeline_spec, + pipeline_path=pipeline_path, + effective_path=effective_path, + force_layout=force_layout, + layout_algorithm=layout_algorithm, + ) + pipeline_spec = self._ensure_mapping(pipeline_spec) + spec_name = pipeline_spec.get("name") + if isinstance(spec_name, str) and spec_name: + pipeline_name = spec_name + + validation_errors = hooks.validate_pipeline_for_run( + pipeline_spec, + pipeline_path=pipeline_path, + effective_path=effective_path, + skip_validation=skip_validation, + ) + if validation_errors and not skip_validation: + raise PipelineRunError("Pipeline validation failed:\n - " + "\n - ".join(validation_errors)) + + pipeline_spec = hooks.before_submit_pipeline_spec( + pipeline_spec, + pipeline_path=pipeline_path, + effective_path=effective_path, + run_args=run_args, + ) + pipeline_spec = self._ensure_mapping(pipeline_spec) + spec_name = pipeline_spec.get("name") + if isinstance(spec_name, str) and spec_name: + pipeline_name = spec_name + + preparation = PipelinePreparationResult( + pipeline_spec=pipeline_spec, + pipeline_name=pipeline_name, + effective_path=effective_path, + ) + return preparation + except Exception as exc: + cleanup_spec = pipeline_spec if isinstance(pipeline_spec, dict) else {} + hooks.cleanup_prepared_pipeline( + preparation + or PipelinePreparationResult( + pipeline_spec=cleanup_spec, + pipeline_name=pipeline_name, + effective_path=effective_path, + ), + error=exc, + ) + raise + + def submit_pipeline_spec_result( + self, + pipeline_name: str, + pipeline_spec: dict[str, Any], + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + run_as: str | None = None, + pipeline_path: str | Path | None = None, + ) -> dict[str, Any]: + """Submit an already prepared spec and return a normalized summary.""" + + submit_payload = self.prepare_submit_payload_from_spec( + copy.deepcopy(pipeline_spec), + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=False, + ) + response = self.submit_prepared_payload(submit_payload, pipeline_path=pipeline_path) + run_id = str(response.get("id")) if response.get("id") is not None else None + root_execution_id = ( + str(response.get("root_execution_id")) if response.get("root_execution_id") is not None else None + ) + return { + "success": True, + "status": "submitted", + "pipeline_name": submit_payload.run_name or pipeline_name, + "run_id": run_id, + "root_execution_id": root_execution_id, + "response": response, + } + + def run_pipeline( + self, + pipeline_path: str | Path, + *, + run_args: dict[str, Any] | None = None, + annotations: dict[str, str] | None = None, + hydrate: bool = True, + run_as: str | None = None, + resolution_overrides: dict[str, Any] | None = None, + wait: bool = False, + max_wait: float | None = 600.0, + poll_interval: float = 10.0, + use_graph_state: bool = False, + retry: int = 0, + max_attempts: int | None = None, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", + exit_on_first_failure: bool = False, + skip_validation: bool = False, + skip_layout: bool = True, + force_layout: bool = False, + layout_algorithm: str | None = None, + open_browser: bool = False, + include_next_steps: bool = False, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Run a pipeline path through generic preparation + lifecycle hooks. + + Path-based runs prepare inside the retry body factory so every retry + re-runs load/hydrate/validation/layout/pre-submit hooks. + """ + + attempts = max_attempts if max_attempts is not None else (retry + 1 if wait else 1) + hooks = self._high_level_hooks() + preparations: dict[int, PipelinePreparationResult] = {} + submit_payloads: dict[int, PipelineSubmitPayload] = {} + + def prepare_attempt(attempt: int) -> PipelinePreparationResult: + preparation = self.prepare_pipeline_for_run( + pipeline_path, + run_args=run_args, + hydrate=hydrate, + resolution_overrides=resolution_overrides, + skip_validation=skip_validation, + skip_layout=skip_layout, + force_layout=force_layout, + layout_algorithm=layout_algorithm, + ) + preparations[attempt] = preparation + return preparation + + def body_factory( + attempt: int, + _previous_context: PipelineRunContext | None, + _error: Exception | None, + ) -> dict[str, Any]: + preparation = prepare_attempt(attempt) + submit_payload = self.prepare_submit_payload_from_spec( + copy.deepcopy(preparation.pipeline_spec), + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=False, + ) + submit_payloads[attempt] = submit_payload + return submit_payload.to_body() + + def metadata_factory( + attempt: int, + previous_context: PipelineRunContext | None, + _error: Exception | None, + ) -> dict[str, Any]: + preparation = preparations.get(attempt) + submit_payload = submit_payloads.get(attempt) + if ( + preparation is None + and previous_context is not None + and previous_context.run_id is None + and previous_context.submit_body is not None + ): + # ``PipelineRunManager`` reuses the previous submit body after + # submit-time exceptions. Mirror the previous preparation + # bookkeeping so metadata/result formatting still point at the + # logical pipeline being retried without re-running dynamic + # body preparation hooks. + preparation = preparations.get(previous_context.attempt) + if preparation is not None: + preparations[attempt] = preparation + submit_payload = submit_payloads.get(previous_context.attempt) + if submit_payload is not None: + submit_payloads[attempt] = submit_payload + if preparation is None: + raise PipelineRunError("Pipeline retry metadata requested before preparation") + return hooks.metadata_for_run( + pipeline_name=(submit_payload.run_name if submit_payload else None) or preparation.pipeline_name, + pipeline_path=pipeline_path, + effective_path=preparation.effective_path, + wait=wait, + open_browser=open_browser, + include_next_steps=include_next_steps, + retry=retry, + max_wait=max_wait, + poll_interval=poll_interval, + extra_metadata=metadata, + ) + + error: Exception | None = None + try: + result = self._run_body_factory( + body_factory, + pipeline_path=pipeline_path, + wait=wait, + max_wait=max_wait, + poll_interval=poll_interval, + use_graph_state=use_graph_state, + max_attempts=attempts, + allow_zero_poll_interval=allow_zero_poll_interval, + timeout_clock=timeout_clock, + exit_on_first_failure=exit_on_first_failure, + metadata_factory=metadata_factory, + ) + context = result.get("context") + attempt = context.attempt if isinstance(context, PipelineRunContext) else max(preparations) + return hooks.format_run_result(result, preparation=preparations[attempt]) + except Exception as exc: + error = exc + raise + finally: + cleaned_preparation_ids: set[int] = set() + for preparation in preparations.values(): + preparation_id = id(preparation) + if preparation_id in cleaned_preparation_ids: + continue + cleaned_preparation_ids.add(preparation_id) + hooks.cleanup_prepared_pipeline(preparation, error=error) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py new file mode 100644 index 0000000..8c67c84 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -0,0 +1,584 @@ +"""`tangle sdk pipeline-runs` command implementation.""" + +from __future__ import annotations + +import json +import pathlib +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .args_container import ArgsContainer +from .cli_helpers import ( + LazyTangleApiClient, + api_arg_specs, + include_env_credentials_for_args, + load_args_or_exit, + optional_path, + print_json, +) +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + LogTypeOption, + TokenOption, +) +from .logger import Logger, logger_for_log_type +from .pipeline_run_annotations import AnnotationManager +from .pipeline_run_manager import ( + PipelineRunError, + PipelineRunHooks, + PipelineRunManager, + parse_json_or_key_values, + parse_key_value_entries, +) +from .pipeline_run_search import normalize_query_input, parse_annotation + +app = App(name="pipeline-runs", help="Submit and inspect Tangle pipeline runs.") +annotations_app = App(name="annotations", help="Work with pipeline-run annotations.") +app.command(annotations_app) + + +def _trusted_hydration_config(args: ArgsContainer) -> dict[str, Any]: + config = getattr(args, "_config", {}).get("trusted_hydration", {}) + return config if isinstance(config, dict) else {} + + +def _trusted_sources_for_args(args: ArgsContainer) -> list[str]: + sources: list[str] = [] + config_sources = _trusted_hydration_config(args).get("trusted_python_sources", []) + if isinstance(config_sources, str): + sources.append(config_sources) + elif isinstance(config_sources, list): + sources.extend(str(source) for source in config_sources) + cli_sources = getattr(args, "trusted_source", None) + if isinstance(cli_sources, str): + sources.append(cli_sources) + elif isinstance(cli_sources, list): + sources.extend(str(source) for source in cli_sources) + return [source for source in sources if source] + + +def _allow_all_hydration_for_args(args: ArgsContainer) -> bool: + if bool(getattr(args, "trusted_hydration_cli", False)): + return True + config = _trusted_hydration_config(args) + return bool(config.get("allow_all", False)) + + +def _api_client(args: ArgsContainer, *, cli_base_url: str | None, command_name: str) -> LazyTangleApiClient: + return LazyTangleApiClient( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, cli_base_url), + command_name=command_name, + ) + + +def _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) -> PipelineRunManager: + return PipelineRunManager( + client=_api_client(args, cli_base_url=cli_base_url, command_name="pipeline-run commands"), + hooks=PipelineRunHooks( + logger=logger, + trusted_python_sources=_trusted_sources_for_args(args), + allow_all_hydration=_allow_all_hydration_for_args(args), + ), + logger=logger, + ) + + +def _run_manager_action(config: str | None, cli_base_url: str | None, specs: dict[str, tuple[Any, ...]], fn): + for args in load_args_or_exit(config, **specs): + try: + logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console")) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + try: + try: + result = fn(_manager(args, cli_base_url=cli_base_url, logger=logger), args) + except PipelineRunError as exc: + raise SystemExit(str(exc)) from exc + if result is not None: + print_json(result) + finally: + finalize_logs() + + +def _run_annotation_action(config: str | None, cli_base_url: str | None, specs: dict[str, tuple[Any, ...]], fn): + for args in load_args_or_exit(config, **specs): + try: + logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console")) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + try: + manager = AnnotationManager( + client=_api_client(args, cli_base_url=cli_base_url, command_name="pipeline-run annotation commands"), + logger=logger, + ) + print_json(fn(manager, args)) + finally: + finalize_logs() + + +@app.command(name="submit") +def pipeline_runs_submit( + pipeline_path: pathlib.Path | None = None, + *, + arg: Annotated[ + list[str] | None, + Parameter(help="Pipeline argument as KEY=VALUE. Repeat for multiple.", negative_iterable=()), + ] = None, + args_json: Annotated[str | None, Parameter(help="Pipeline arguments as a JSON object.")] = None, + annotation: Annotated[ + list[str] | None, + Parameter(help="Run annotation as KEY=VALUE. Repeat for multiple.", negative_iterable=()), + ] = None, + hydrate: Annotated[bool | None, Parameter(help="Hydrate refs before submit.")] = True, + dry_run: Annotated[ + bool | None, + Parameter(help="Hydrate and print the submit payload without creating a run."), + ] = None, + run_as: Annotated[ + str | None, + Parameter(help="Downstream extension point; unsupported by the OSS default hooks."), + ] = None, + trusted_source: Annotated[ + list[str] | None, + Parameter( + name="--trusted-source", + help="Trusted local_from_python source root or glob. Repeat for multiple.", + negative_iterable=(), + ), + ] = None, + trusted_hydration: Annotated[ + bool | None, + Parameter( + name="--trusted-hydration", + help="Allow all local_from_python execution during hydration for trusted inputs.", + ), + ] = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Hydrate and submit a local pipeline YAML file as a run.""" + + specs = { + "pipeline_path": ("pipeline_path", pipeline_path, None, False, True, optional_path), + "arg": (arg, None), + "args_json": (args_json, None), + "args_config": ("args", None, None, True), + "annotation": (annotation, None), + "hydrate": (hydrate, True), + "dry_run": (dry_run, None), + "run_as": (run_as, None), + "trusted_source": (trusted_source, None), + "trusted_hydration_cli": ("trusted_hydration_cli", trusted_hydration, None, False), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(manager: PipelineRunManager, args: ArgsContainer) -> dict[str, Any]: + kwargs = { + "run_args": parse_json_or_key_values(args.args_json or args.args_config, args.arg), + "annotations": parse_key_value_entries(args.annotation), + "hydrate": bool(args.hydrate), + "run_as": args.run_as, + } + if args.dry_run: + return manager.build_submit_body(args.pipeline_path, **kwargs) + return manager.submit_pipeline(args.pipeline_path, **kwargs) + + _run_manager_action(config, base_url, specs, action) + + +@app.command(name="details") +def pipeline_runs_details( + run_id: str | None = None, + *, + execution_id: str | None = None, + include_implementations: bool | None = None, + include_annotations: bool | None = None, + include_execution_state: bool | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Print run details, including root execution details.""" + specs = { + "run_id": (run_id,), + "execution_id": (execution_id, None), + "include_implementations": (include_implementations, None), + "include_annotations": (include_annotations, None), + "include_execution_state": (include_execution_state, None), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_manager_action( + config, + base_url, + specs, + lambda manager, args: manager.get_run_details( + args.run_id, + include_annotations=bool(args.include_annotations), + include_execution_state=bool(args.include_execution_state), + include_implementations=bool(args.include_implementations), + execution_id=args.execution_id, + ), + ) + + +@app.command(name="status") +def pipeline_runs_status( + run_id: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Print a pipeline run and derived status summary.""" + specs = { + "run_id": (run_id,), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(manager: PipelineRunManager, args: ArgsContainer) -> dict[str, Any]: + run = manager.get_run(args.run_id, include_execution_stats=True) + return {"run": run, "status": manager.status_from_run(run) or "UNKNOWN"} + + _run_manager_action(config, base_url, specs, action) + + +@app.command(name="graph-state") +def pipeline_runs_graph_state( + execution_id: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Print graph execution state for an execution id.""" + specs = { + "execution_id": (execution_id,), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_manager_action(config, base_url, specs, lambda manager, args: manager.graph_state(args.execution_id)) + + +@app.command(name="cancel") +def pipeline_runs_cancel( + run_id: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Cancel a pipeline run.""" + specs = { + "run_id": (run_id,), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_manager_action(config, base_url, specs, lambda manager, args: manager.cancel_run(args.run_id)) + + +@app.command(name="wait") +def pipeline_runs_wait( + run_id: str | None = None, + *, + max_wait: float = 600.0, + poll_interval: float = 10.0, + exit_on_first_failure: bool = False, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Poll a run until terminal state or bounded timeout.""" + specs = { + "run_id": (run_id,), + "max_wait": (max_wait, 600.0), + "poll_interval": (poll_interval, 10.0), + "exit_on_first_failure": (exit_on_first_failure, False), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_manager_action( + config, + base_url, + specs, + lambda manager, args: manager.wait_for_completion( + args.run_id, + max_wait=float(args.max_wait), + poll_interval=float(args.poll_interval), + exit_on_first_failure=bool(args.exit_on_first_failure), + ), + ) + + +@app.command(name="logs") +def pipeline_runs_logs( + execution_id: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Print Tangle API container logs for an execution id.""" + specs = { + "execution_id": (execution_id,), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(manager: PipelineRunManager, args: ArgsContainer) -> object: + result = manager.logs(args.execution_id) + if isinstance(result, dict) and isinstance(result.get("log_text"), str): + print(result["log_text"], end="" if result["log_text"].endswith("\n") else "\n") + return None + return result + + _run_manager_action(config, base_url, specs, action) + + +@app.command(name="search") +def pipeline_runs_search( + query: str | None = None, + *, + filter_query: str | None = None, + name: str | None = None, + created_by: str | None = None, + annotation: Annotated[ + list[str] | None, + Parameter(help="Annotation filter as key or key=value. Repeat for multiple.", negative_iterable=()), + ] = None, + annotations_json: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + local_time: bool | None = None, + raw_query: Annotated[ + str | None, + Parameter(name="--query", help="Raw filter_query JSON, plain or URL-encoded."), + ] = None, + limit: int | None = None, + page_token: str | None = None, + include_pipeline_names: bool | None = None, + include_execution_stats: bool | None = None, + output: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Search/list pipeline runs using simple or rich Tangle API filters.""" + specs = { + "query": ("filter", query, None, False), + "filter_query": (filter_query, None), + "name": (name, None), + "created_by": (created_by, None), + "annotation": (annotation, None), + "annotations_json": (annotations_json, None), + "start_date": (start_date, None), + "end_date": (end_date, None), + "local_time": (local_time, None), + "raw_query": (raw_query, None), + "limit": (limit, None), + "page_token": (page_token, None), + "include_pipeline_names": (include_pipeline_names, None), + "include_execution_stats": (include_execution_stats, None), + "output": (output, "json"), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(manager: PipelineRunManager, args: ArgsContainer) -> object: + rich_search = any( + getattr(args, attr) + for attr in ( + "name", + "created_by", + "annotation", + "annotations_json", + "start_date", + "end_date", + "raw_query", + "limit", + ) + ) + if not rich_search: + return manager.search_runs( + filter=args.query, + filter_query=args.filter_query, + page_token=args.page_token, + include_pipeline_names=args.include_pipeline_names, + include_execution_stats=args.include_execution_stats, + ) + + annotations: dict[str, str | None] | None = None + if args.annotation: + annotations = {} + for item in args.annotation: + key, value = parse_annotation(str(item)) + annotations[key] = value + if args.annotations_json: + loaded = json.loads(args.annotations_json) + if not isinstance(loaded, dict): + raise PipelineRunError("--annotations-json must be a JSON object") + annotations = annotations or {} + annotations.update({str(key): value for key, value in loaded.items()}) + parsed_query = normalize_query_input(args.raw_query) if args.raw_query else None + result = manager.search_pipeline_runs( + name=args.name, + created_by=args.created_by, + annotations=annotations, + start_date=args.start_date, + end_date=args.end_date, + local_time=bool(args.local_time), + query=parsed_query, + limit=int(args.limit or 10), + page_token=args.page_token, + ) + if "error" in result: + raise PipelineRunError(str(result["error"])) + if str(args.output or "json").lower() == "table": + print(result.get("cli_table", "")) + return None + return result + + _run_manager_action(config, base_url, specs, action) + + +@app.command(name="export") +def pipeline_runs_export( + run_id: str | None = None, + *, + output: pathlib.Path | None = None, + dehydrate: Annotated[ + bool | None, + Parameter(help="Dehydrate exported pipeline specs into portable component refs."), + ] = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Export a run's root pipeline spec to YAML.""" + specs = { + "run_id": (run_id,), + "output": (output, None, optional_path), + "dehydrate": (dehydrate, None), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(manager: PipelineRunManager, args: ArgsContainer) -> object: + result = manager.export_run(args.run_id, args.output, dehydrate=bool(args.dehydrate)) + if args.output is None and "yaml" in result: + print(result["yaml"], end="" if result["yaml"].endswith("\n") else "\n") + return None + return result + + _run_manager_action(config, base_url, specs, action) + + +@annotations_app.command(name="list") +def pipeline_runs_annotations_list( + run_id: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + specs = { + "run_id": (run_id,), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_annotation_action(config, base_url, specs, lambda manager, args: manager.list_annotations(args.run_id)) + + +@annotations_app.command(name="set") +def pipeline_runs_annotations_set( + run_id: str | None = None, + key: str | None = None, + value: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + specs = { + "run_id": (run_id,), + "key": (key,), + "value": (value, None), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_annotation_action( + config, + base_url, + specs, + lambda manager, args: manager.set_annotation(args.run_id, args.key, args.value), + ) + + +@annotations_app.command(name="delete") +def pipeline_runs_annotations_delete( + run_id: str | None = None, + key: str | None = None, + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + specs = { + "run_id": (run_id,), + "key": (key,), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + _run_annotation_action( + config, + base_url, + specs, + lambda manager, args: manager.delete_annotation(args.run_id, args.key), + ) diff --git a/packages/tangle-cli/src/tangle_cli/pipelines.py b/packages/tangle-cli/src/tangle_cli/pipelines.py new file mode 100644 index 0000000..06ad8f7 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -0,0 +1,581 @@ +"""Local helpers for working with Tangle pipeline component specs. + +This module intentionally stays API-free: it validates, diagrams, and lays out +pipeline YAML files that are already present on disk. +""" + +from __future__ import annotations + +import json +import re +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +import yaml + +from .utils import dump_yaml + +PIPELINE_GRAPH_PATH = "implementation.graph" +TASKS_PATH = f"{PIPELINE_GRAPH_PATH}.tasks" +POSITION_ANNOTATION = "editor.position" + + +class PipelineValidationError(ValueError): + """Raised when a local pipeline spec cannot be parsed or validated.""" + + +@dataclass(frozen=True) +class LayoutResult: + """Summary of a layout operation.""" + + output_path: Path + tasks_positioned: int + graphs_positioned: int + + +@dataclass(frozen=True) +class HydrateResult: + """Summary of a hydrate operation.""" + + content: str + output_path: Path | None + resolved_components: int + + +@dataclass(frozen=True) +class _LoadedComponent: + digest: str + spec: dict[str, Any] + base_dir: Path + + +# --------------------------------------------------------------------------- +# YAML loading / validation +# --------------------------------------------------------------------------- + + +def load_pipeline_file(path: str | Path) -> dict[str, Any]: + """Load a pipeline YAML file and return its top-level mapping. + + Raises: + PipelineValidationError: If the file cannot be read, parsed, or does + not contain a top-level mapping. + """ + + pipeline_path = Path(path) + try: + text = pipeline_path.read_text(encoding="utf-8") + except OSError as exc: + raise PipelineValidationError(f"Unable to read {pipeline_path}: {exc}") from exc + + try: + loaded = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise PipelineValidationError(f"Invalid YAML in {pipeline_path}: {exc}") from exc + + if not isinstance(loaded, dict): + raise PipelineValidationError("Pipeline YAML must contain a top-level mapping") + + return loaded + + +def validate_pipeline_file(path: str | Path) -> dict[str, Any]: + """Load and validate a pipeline YAML file, returning the parsed spec.""" + + pipeline = load_pipeline_file(path) + validate_pipeline_spec(pipeline) + return pipeline + + +def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None: + """Validate the OSS-compatible local pipeline shape. + + This is a pragmatic validator for local authoring workflows. It focuses on + the graph structure that the CLI commands consume rather than provider-specific + deployment extensions or remote API fields. + """ + + errors: list[str] = [] + _validate_root_pipeline(pipeline, errors) + if errors: + details = "\n".join(f"- {error}" for error in errors) + raise PipelineValidationError(f"Pipeline validation failed:\n{details}") + + +def _validate_root_pipeline(pipeline: Mapping[str, Any], errors: list[str]) -> None: + name = pipeline.get("name") + if not isinstance(name, str) or not name.strip(): + errors.append("name must be a non-empty string") + + implementation = pipeline.get("implementation") + if not isinstance(implementation, Mapping): + errors.append("implementation must be an object") + return + + graph = implementation.get("graph") + if not isinstance(graph, Mapping): + errors.append(f"{PIPELINE_GRAPH_PATH} must be an object") + return + + _validate_graph_spec(pipeline, "pipeline", errors, require_tasks=True) + + +def _validate_graph_spec( + spec: Mapping[str, Any], + path: str, + errors: list[str], + *, + require_tasks: bool, +) -> None: + implementation = spec.get("implementation") + if not isinstance(implementation, Mapping): + if require_tasks: + errors.append(f"{path}.implementation must be an object") + return + + graph = implementation.get("graph") + if not isinstance(graph, Mapping): + if require_tasks: + errors.append(f"{path}.{PIPELINE_GRAPH_PATH} must be an object") + return + + tasks = graph.get("tasks") + if tasks is None and not require_tasks: + return + if not isinstance(tasks, Mapping): + errors.append(f"{path}.{TASKS_PATH} must be an object") + return + + task_names: set[str] = set() + for name in tasks.keys(): + if not isinstance(name, str): + errors.append(f"{path}.{TASKS_PATH} task ids must be strings") + continue + task_names.add(name) + edges: set[tuple[str, str]] = set() + + for task_name, raw_task in tasks.items(): + task_path = f"{path}.{TASKS_PATH}.{task_name}" + if not isinstance(task_name, str): + continue + if not isinstance(raw_task, Mapping): + errors.append(f"{task_path} must be an object") + continue + + component_ref = raw_task.get("componentRef") + if not isinstance(component_ref, Mapping): + errors.append(f"{task_path}.componentRef must be an object") + else: + _validate_component_ref(component_ref, f"{task_path}.componentRef", errors) + + dependencies = raw_task.get("dependencies", []) + if dependencies is None: + dependencies = [] + if not isinstance(dependencies, list): + errors.append(f"{task_path}.dependencies must be a list of task ids") + else: + for dep in dependencies: + if not isinstance(dep, str): + errors.append(f"{task_path}.dependencies entries must be strings") + continue + if dep not in task_names: + errors.append(f"{task_path}.dependencies references unknown task {dep!r}") + else: + edges.add((dep, str(task_name))) + + arguments = raw_task.get("arguments", {}) + if arguments is not None and not isinstance(arguments, Mapping): + errors.append(f"{task_path}.arguments must be an object") + else: + for referenced_task in _extract_task_output_refs(arguments or {}): + if referenced_task not in task_names: + errors.append( + f"{task_path}.arguments references unknown task {referenced_task!r}" + ) + else: + edges.add((referenced_task, str(task_name))) + + if isinstance(component_ref, Mapping): + nested_spec = component_ref.get("spec") + if isinstance(nested_spec, Mapping): + _validate_graph_spec( + nested_spec, + f"{task_path}.componentRef.spec", + errors, + require_tasks=False, + ) + + output_values = graph.get("outputValues", {}) + if output_values is not None and not isinstance(output_values, Mapping): + errors.append(f"{path}.{PIPELINE_GRAPH_PATH}.outputValues must be an object") + else: + for referenced_task in _extract_task_output_refs(output_values or {}): + if referenced_task not in task_names: + errors.append( + f"{path}.{PIPELINE_GRAPH_PATH}.outputValues references unknown task " + f"{referenced_task!r}" + ) + + cycle = _find_cycle(task_names, edges) + if cycle: + errors.append(f"{path}.{TASKS_PATH} contains a dependency cycle: {' -> '.join(cycle)}") + + +def _validate_component_ref(ref: Mapping[str, Any], path: str, errors: list[str]) -> None: + has_selector = any(ref.get(key) for key in ("name", "digest", "tag", "url", "text")) + nested_spec = ref.get("spec") + if nested_spec is not None and not isinstance(nested_spec, Mapping): + errors.append(f"{path}.spec must be an object when provided") + if isinstance(nested_spec, Mapping): + has_selector = True + if not has_selector: + errors.append( + f"{path} must include at least one of name, digest, tag, url, text, or spec" + ) + + +def _extract_task_output_refs(value: Any) -> set[str]: + refs: set[str] = set() + if isinstance(value, Mapping): + task_output = value.get("taskOutput") + if isinstance(task_output, Mapping) and isinstance(task_output.get("taskId"), str): + refs.add(task_output["taskId"]) + for nested in value.values(): + refs.update(_extract_task_output_refs(nested)) + elif isinstance(value, list): + for item in value: + refs.update(_extract_task_output_refs(item)) + return refs + + +def _find_cycle(nodes: Iterable[str], edges: Iterable[tuple[str, str]]) -> list[str]: + adjacency: dict[str, list[str]] = {node: [] for node in nodes} + for source, target in edges: + adjacency.setdefault(source, []).append(target) + + visiting: set[str] = set() + visited: set[str] = set() + stack: list[str] = [] + + def visit(node: str) -> list[str] | None: + if node in visited: + return None + if node in visiting: + try: + start = stack.index(node) + except ValueError: + return [node, node] + return stack[start:] + [node] + + visiting.add(node) + stack.append(node) + for neighbor in sorted(adjacency.get(node, [])): + cycle = visit(neighbor) + if cycle: + return cycle + stack.pop() + visiting.remove(node) + visited.add(node) + return None + + for node in sorted(adjacency): + cycle = visit(node) + if cycle: + return cycle + return [] + + +# --------------------------------------------------------------------------- +# Mermaid diagrams +# --------------------------------------------------------------------------- + + +def generate_mermaid(pipeline_spec: Mapping[str, Any], name: str | None = None) -> str: + """Generate GitHub-compatible Mermaid diagrams for a pipeline spec.""" + + display_name = name or str(pipeline_spec.get("name") or "Pipeline") + tasks = _tasks_for_spec(pipeline_spec) + if not tasks: + return f"No tasks found in pipeline `{display_name}`." + + sections = [f"### {display_name}\n", _render_mermaid_graph(tasks)] + for heading_level, task_id, nested_spec in _iter_nested_graph_specs(tasks, level=3): + nested_name = str(nested_spec.get("name") or task_id) + sections.append(f"\n{'#' * heading_level} Subgraph: {nested_name} (`{task_id}`)\n") + sections.append(_render_mermaid_graph(_tasks_for_spec(nested_spec))) + return "\n".join(sections) + + +def _render_mermaid_graph(tasks: Mapping[str, Any]) -> str: + lines = ["```mermaid", "flowchart LR"] + + for task_id, task_spec in tasks.items(): + label = _task_label(str(task_id), task_spec) + lines.append(f" {_safe_mermaid_id(str(task_id))}[\"{_escape_mermaid_label(label)}\"]") + + edges = _dependency_edges(tasks) + if edges: + lines.append("") + for source, target in sorted(edges): + lines.append(f" {_safe_mermaid_id(source)} --> {_safe_mermaid_id(target)}") + + lines.append("```") + return "\n".join(lines) + + +def _iter_nested_graph_specs( + tasks: Mapping[str, Any], + *, + level: int, +) -> Iterable[tuple[int, str, Mapping[str, Any]]]: + for task_id, task_spec in tasks.items(): + if not isinstance(task_spec, Mapping): + continue + spec = _component_ref_spec(task_spec) + if spec is None or not _tasks_for_spec(spec): + continue + yield level, str(task_id), spec + yield from _iter_nested_graph_specs(_tasks_for_spec(spec), level=level + 1) + + +def _task_label(task_id: str, task_spec: Any) -> str: + if not isinstance(task_spec, Mapping): + return task_id + + component_ref = task_spec.get("componentRef") + ref_name = component_ref.get("name") if isinstance(component_ref, Mapping) else None + spec = _component_ref_spec(task_spec) + spec_name = spec.get("name") if spec is not None else None + label = str(ref_name or spec_name or task_id) + if spec is not None and _tasks_for_spec(spec): + return f"{label} [subgraph]" + return label + + +def _safe_mermaid_id(task_id: str) -> str: + safe_id = re.sub(r"\W+", "_", task_id).strip("_") or "task" + if safe_id[0].isdigit(): + safe_id = f"task_{safe_id}" + return safe_id + + +def _escape_mermaid_label(label: str) -> str: + return label.replace("\\", "\\\\").replace('"', "\\\"") + + +# --------------------------------------------------------------------------- +# Hydration +# --------------------------------------------------------------------------- + + +def hydrate_pipeline_file( + pipeline_path: str | Path, + *, + output: str | Path | None = None, + overrides: Mapping[str, Any] | None = None, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, + include_env_credentials: bool = True, + client: Any | None = None, + logger: Any | None = None, + trusted_python_sources: list[str] | None = None, + allow_all_hydration: bool = False, +) -> HydrateResult: + """Hydrate a local pipeline YAML file using the ported TD hydrator.""" + + from .pipeline_hydrator import HydrationError, PipelineHydrator + + output_path = Path(output) if output is not None else None + try: + hydrator = PipelineHydrator( + client=client, + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + include_env_credentials=include_env_credentials, + logger=logger, + resolution_overrides=dict(overrides or {}), + trusted_python_sources=trusted_python_sources, + allow_all_hydration=allow_all_hydration, + ) + hydrated = hydrator.hydrate_file( + pipeline_path, + output_file=output_path, + overrides={str(key): str(value) for key, value in (overrides or {}).items()}, + ) + validate_pipeline_spec(hydrated.data) + except HydrationError as exc: + raise PipelineValidationError(str(exc)) from exc + + return HydrateResult( + content=hydrated.content, + output_path=output_path, + resolved_components=hydrated.resolved_count, + ) + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + + +def layout_pipeline_file( + pipeline_path: str | Path, + *, + output: str | Path | None = None, + recursive: bool = False, + x_spacing: int = 300, + y_spacing: int = 120, +) -> LayoutResult: + """Apply a deterministic left-to-right layout to a pipeline YAML file.""" + + source_path = Path(pipeline_path) + pipeline = validate_pipeline_file(source_path) + tasks_positioned, graphs_positioned = layout_pipeline_spec( + pipeline, + recursive=recursive, + x_spacing=x_spacing, + y_spacing=y_spacing, + ) + + output_path = Path(output) if output is not None else source_path + output_path.write_text(dump_yaml(pipeline), encoding="utf-8") + return LayoutResult( + output_path=output_path, + tasks_positioned=tasks_positioned, + graphs_positioned=graphs_positioned, + ) + + +def layout_pipeline_spec( + pipeline: Mapping[str, Any], + *, + recursive: bool = False, + x_spacing: int = 300, + y_spacing: int = 120, +) -> tuple[int, int]: + """Mutate a parsed pipeline spec with deterministic task coordinates.""" + + tasks_positioned = _layout_graph_spec(pipeline, x_spacing=x_spacing, y_spacing=y_spacing) + graphs_positioned = 1 if tasks_positioned else 0 + + if recursive: + for _task_id, nested_spec in _iter_mutable_nested_specs(_tasks_for_spec(pipeline)): + nested_count = _layout_graph_spec(nested_spec, x_spacing=x_spacing, y_spacing=y_spacing) + if nested_count: + tasks_positioned += nested_count + graphs_positioned += 1 + + return tasks_positioned, graphs_positioned + + +def _layout_graph_spec(spec: Mapping[str, Any], *, x_spacing: int, y_spacing: int) -> int: + tasks = _tasks_for_spec(spec) + if not tasks: + return 0 + + layers = _task_layers(tasks) + for layer_index, layer in enumerate(layers): + for row_index, task_name in enumerate(layer): + raw_task = tasks[task_name] + if not isinstance(raw_task, dict): + continue + annotations = raw_task.setdefault("annotations", {}) + if not isinstance(annotations, dict): + annotations = {} + raw_task["annotations"] = annotations + annotations[POSITION_ANNOTATION] = json.dumps( + {"x": layer_index * x_spacing, "y": row_index * y_spacing} + ) + return len(tasks) + + +def _task_layers(tasks: Mapping[str, Any]) -> list[list[str]]: + task_names = [name for name in tasks.keys() if isinstance(name, str)] + task_name_set = set(task_names) + outgoing: dict[str, set[str]] = {name: set() for name in task_names} + incoming_count: dict[str, int] = {name: 0 for name in task_names} + + for source, target in _dependency_edges(tasks): + if source not in task_name_set or target not in task_name_set: + continue + if target not in outgoing[source]: + outgoing[source].add(target) + incoming_count[target] += 1 + + ready = deque(name for name in task_names if incoming_count[name] == 0) + layer_by_task: dict[str, int] = {name: 0 for name in ready} + + while ready: + current = ready.popleft() + for target in sorted(outgoing[current], key=task_names.index): + layer_by_task[target] = max(layer_by_task.get(target, 0), layer_by_task[current] + 1) + incoming_count[target] -= 1 + if incoming_count[target] == 0: + ready.append(target) + + # Validation rejects cycles, but keep layout deterministic if called directly. + for name in task_names: + if name not in layer_by_task: + layer_by_task[name] = 0 + + max_layer = max(layer_by_task.values(), default=0) + layers: list[list[str]] = [[] for _ in range(max_layer + 1)] + for name in task_names: + layers[layer_by_task[name]].append(name) + return layers + + +def _dependency_edges(tasks: Mapping[str, Any]) -> set[tuple[str, str]]: + edges: set[tuple[str, str]] = set() + task_names = {name for name in tasks.keys() if isinstance(name, str)} + + for task_id, task_spec in tasks.items(): + if not isinstance(task_id, str) or not isinstance(task_spec, Mapping): + continue + target = task_id + dependencies = task_spec.get("dependencies", []) + if isinstance(dependencies, list): + for dependency in dependencies: + if isinstance(dependency, str) and dependency in task_names: + edges.add((dependency, target)) + for referenced_task in _extract_task_output_refs(task_spec.get("arguments", {})): + if referenced_task in task_names: + edges.add((referenced_task, target)) + + return edges + + +def _tasks_for_spec(spec: Mapping[str, Any]) -> Mapping[str, Any]: + implementation = spec.get("implementation") + if not isinstance(implementation, Mapping): + return {} + graph = implementation.get("graph") + if not isinstance(graph, Mapping): + return {} + tasks = graph.get("tasks") + return tasks if isinstance(tasks, Mapping) else {} + + +def _component_ref_spec(task_spec: Mapping[str, Any]) -> Mapping[str, Any] | None: + component_ref = task_spec.get("componentRef") + if not isinstance(component_ref, Mapping): + return None + spec = component_ref.get("spec") + return spec if isinstance(spec, Mapping) else None + + +def _iter_mutable_nested_specs(tasks: Mapping[str, Any]) -> Iterable[tuple[str, Mapping[str, Any]]]: + for task_id, task_spec in tasks.items(): + if not isinstance(task_spec, Mapping): + continue + spec = _component_ref_spec(task_spec) + if spec is None or not _tasks_for_spec(spec): + continue + yield str(task_id), spec + yield from _iter_mutable_nested_specs(_tasks_for_spec(spec)) diff --git a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py new file mode 100644 index 0000000..4f545d7 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py @@ -0,0 +1,271 @@ +"""`tangle sdk pipelines` local pipeline commands.""" + +from __future__ import annotations + +import pathlib +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .cli_helpers import LazyTangleApiClient, load_config_or_exit, optional_path +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + LogTypeOption, + TokenOption, +) +from .logger import logger_for_log_type +from .pipelines import ( + PipelineValidationError, + generate_mermaid, + hydrate_pipeline_file, + layout_pipeline_file, + validate_pipeline_file, +) + +app = App( + name="pipelines", + help="Validate and visualize local Tangle pipeline specs.", +) + + +@app.command(name="validate") +def pipelines_validate( + pipeline_path: pathlib.Path, + *, + log_type: LogTypeOption = "console", +) -> None: + """Validate a local pipeline YAML file.""" + + logger, finalize_logs = logger_for_log_type(log_type) + try: + try: + validate_pipeline_file(pipeline_path) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + print(f"Valid pipeline: {pipeline_path}") + finally: + finalize_logs() + + +@app.command(name="diagram") +def pipelines_diagram( + pipeline_path: pathlib.Path, + *, + log_type: LogTypeOption = "console", +) -> None: + """Print a Mermaid dependency diagram for a local pipeline YAML file.""" + + logger, finalize_logs = logger_for_log_type(log_type) + try: + try: + pipeline = validate_pipeline_file(pipeline_path) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + print(generate_mermaid(pipeline)) + finally: + finalize_logs() + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _header_entries(cli_header: list[str] | None, config: dict[str, object]) -> list[str] | None: + if cli_header is not None: + return cli_header + config_header = config.get("header") + if isinstance(config_header, list): + return [str(entry) for entry in config_header] + if isinstance(config_header, str): + return [config_header] + return None + + +def _trusted_hydration_config(config: dict[str, object]) -> dict[str, Any]: + trusted = config.get("trusted_hydration", {}) + return trusted if isinstance(trusted, dict) else {} + + +def _trusted_sources( + cli_sources: list[str] | None, + config: dict[str, object], +) -> list[str]: + sources: list[str] = [] + config_sources = _trusted_hydration_config(config).get("trusted_python_sources", []) + if isinstance(config_sources, str): + sources.append(config_sources) + elif isinstance(config_sources, list): + sources.extend(str(source) for source in config_sources) + if cli_sources: + sources.extend(cli_sources) + return [source for source in sources if source] + + +def _allow_all_hydration( + trusted_hydration: bool | None, + config: dict[str, object], +) -> bool: + return bool(trusted_hydration or _trusted_hydration_config(config).get("allow_all", False)) + + +def _parse_vars(values: list[str] | dict[str, object] | None) -> dict[str, str]: + parsed: dict[str, str] = {} + if isinstance(values, dict): + return {str(key): str(value) for key, value in values.items()} + for value in values or []: + if "=" not in value: + raise SystemExit("--var entries must use KEY=VALUE syntax") + key, parsed_value = value.split("=", 1) + if not key: + raise SystemExit("--var entries must use KEY=VALUE syntax") + parsed[key] = parsed_value + return parsed + + +@app.command(name="hydrate") +def pipelines_hydrate( + pipeline_path: pathlib.Path, + *, + output: Annotated[ + pathlib.Path | None, + Parameter( + name="--output", + alias="-o", + help="Output path. Defaults to printing hydrated YAML to stdout.", + ), + ] = None, + var: Annotated[ + list[str] | None, + Parameter( + name="--var", + help="Template override as KEY=VALUE. Repeat for multiple overrides.", + negative_iterable=(), + ), + ] = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + trusted_source: Annotated[ + list[str] | None, + Parameter( + name="--trusted-source", + help="Trusted local_from_python source root or glob. Repeat for multiple.", + negative_iterable=(), + ), + ] = None, + trusted_hydration: Annotated[ + bool | None, + Parameter( + name="--trusted-hydration", + help="Allow all local_from_python execution during hydration for trusted inputs.", + ), + ] = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Hydrate a local pipeline YAML file.""" + + config_values = load_config_or_exit(config) + config_base_url = _optional_str(config_values.get("base_url")) + resolved_base_url = base_url if base_url is not None else config_base_url + include_env_credentials = not (base_url is None and config_base_url is not None) + resolved_var = var if var is not None else config_values.get("var") + resolved_token = token + if resolved_token is None: + resolved_token = _optional_str(config_values.get("token")) + + logger, finalize_logs = logger_for_log_type(log_type) + try: + result = hydrate_pipeline_file( + pipeline_path, + output=output or optional_path(config_values.get("output")), + overrides=_parse_vars(resolved_var), + base_url=resolved_base_url, + token=resolved_token, + auth_header=( + auth_header + if auth_header is not None + else _optional_str(config_values.get("auth_header")) + ), + header=_header_entries(header, config_values), + include_env_credentials=include_env_credentials, + logger=logger, + trusted_python_sources=_trusted_sources(trusted_source, config_values), + allow_all_hydration=_allow_all_hydration(trusted_hydration, config_values), + client=LazyTangleApiClient( + command_name="pipeline hydration with API-backed component references", + base_url=resolved_base_url, + token=resolved_token, + auth_header=( + auth_header + if auth_header is not None + else _optional_str(config_values.get("auth_header")) + ), + header=_header_entries(header, config_values), + include_env_credentials=include_env_credentials, + ), + ) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + finally: + finalize_logs() + + if result.output_path is None: + print(result.content, end="" if result.content.endswith("\n") else "\n") + else: + print( + f"Hydrated {pipeline_path} -> {result.output_path} " + f"({result.resolved_components} component(s) resolved)." + ) + + +@app.command(name="layout") +def pipelines_layout( + pipeline_path: pathlib.Path, + *, + output: Annotated[ + pathlib.Path | None, + Parameter( + name="--output", + alias="-o", + help="Output path. Defaults to overwriting the input file.", + ), + ] = None, + recursive: Annotated[ + bool | None, + Parameter(help="Also layout nested graph component specs."), + ] = None, + x_spacing: Annotated[ + int, + Parameter(help="Horizontal spacing between dependency layers."), + ] = 300, + y_spacing: Annotated[ + int, + Parameter(help="Vertical spacing between tasks in the same layer."), + ] = 120, + log_type: LogTypeOption = "console", +) -> None: + """Add or update editor.position annotations in a local pipeline YAML file.""" + + logger, finalize_logs = logger_for_log_type(log_type) + try: + result = layout_pipeline_file( + pipeline_path, + output=output, + recursive=bool(recursive), + x_spacing=x_spacing, + y_spacing=y_spacing, + ) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + finally: + finalize_logs() + print( + f"Positioned {result.tasks_positioned} task(s) across " + f"{result.graphs_positioned} graph(s)." + ) + print(f"Wrote layout to: {result.output_path}") diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py new file mode 100644 index 0000000..de4f70a --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -0,0 +1,373 @@ +"""`tangle sdk published-components` command implementation.""" + +from __future__ import annotations + +import pathlib +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .cli_helpers import ( + LazyTangleApiClient, + api_arg_specs, + include_env_credentials_for_args, + load_args_or_exit, + optional_path, + print_json, +) +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + LogTypeOption, + TokenOption, +) +from .component_publisher import ComponentPublisher, deprecate_component +from .logger import logger_for_log_type + + +def _client_from_options( + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | str | None = None, + include_env_credentials: bool = True, + command_name: str = "published-component commands", +) -> LazyTangleApiClient: + """Create a lazy static client proxy for published-component commands. + + Kept as a module-level seam so downstream wrappers/tests can monkeypatch + client construction without replacing the shared LazyTangleApiClient class. + """ + + return LazyTangleApiClient( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + include_env_credentials=include_env_credentials, + command_name=command_name, + ) + + +app = App( + name="published-components", + help="Inspect and search published Tangle components from the registry.", +) + + +@app.command(name="search") +def published_components_search( + name: str | None = None, + *, + include_deprecated: bool | None = None, + published_by: str | None = None, + digest: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Search published component metadata.""" + + for args in load_args_or_exit( + config, + name=(name, None), + include_deprecated=(include_deprecated, None), + published_by=(published_by, None), + digest=(digest, None), + log_type=(log_type, "console"), + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, base_url), + command_name="published-component commands", + ) + if require_available := getattr(client, "require_available", None): + require_available() + + from .component_inspector import ComponentInspector + + print_json( + ComponentInspector(client=client, logger=logger, base_url=args.base_url).search_components( + name=args.name, + include_deprecated=bool(args.include_deprecated), + published_by=args.published_by, + digest=args.digest, + ) + ) + finally: + finalize_logs() + + +@app.command(name="inspect") +def published_components_inspect( + name: str | None = None, + *, + digest: str | None = None, + all_versions: bool | None = None, + include_deprecated: bool | None = None, + follow_deprecated: bool | None = None, + full_spec: bool | None = None, + published_by: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Inspect a published component by exact name or digest.""" + + for args in load_args_or_exit( + config, + name=(name, None), + digest=(digest, None), + all_versions=(all_versions, None), + include_deprecated=(include_deprecated, None), + follow_deprecated=(follow_deprecated, None), + full_spec=(full_spec, None), + published_by=(published_by, None), + log_type=(log_type, "console"), + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + if bool(args.name) == bool(args.digest): + raise SystemExit("Provide exactly one of NAME or --digest DIGEST") + + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, base_url), + command_name="published-component commands", + ) + if require_available := getattr(client, "require_available", None): + require_available() + from .component_inspector import ComponentInspector + + inspector = ComponentInspector(client=client, logger=logger, base_url=args.base_url) + if args.digest: + result = inspector.inspect_by_digest( + args.digest, + full_spec=bool(args.full_spec), + follow_deprecated=bool(args.follow_deprecated), + ) + else: + result = inspector.inspect_by_name( + args.name or "", + include_all_versions=bool(args.all_versions), + include_deprecated=bool(args.include_deprecated), + full_spec=bool(args.full_spec), + published_by=args.published_by, + ) + print_json(result) + finally: + finalize_logs() + + +@app.command(name="library") +def published_components_library( + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Print the curated standard component library.""" + + for args in load_args_or_exit( + config, + log_type=(log_type, "console"), + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, base_url), + command_name="published-component commands", + ) + if require_available := getattr(client, "require_available", None): + require_available() + + from .component_inspector import ComponentInspector + + print_json(ComponentInspector(client=client, logger=logger, base_url=args.base_url).get_standard_library()) + finally: + finalize_logs() + + +@app.command(name="publish") +def published_components_publish( + component_path: pathlib.Path | None = None, + *, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: Annotated[ + str | None, + Parameter(help="Custom annotations as a JSON object."), + ] = None, + dry_run: bool | None = None, + git_remote_sha: str | None = None, + git_remote_branch: str | None = None, + git_remote_url: str | None = None, + git_root: pathlib.Path | None = None, + published_by: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Publish one component YAML file to a Tangle component registry.""" + + all_args = load_args_or_exit( + config, + component_path=("component_path", component_path, None, False, True, optional_path), + image=(image, None), + name=(name, None), + description=(description, None), + annotations=("annotations", annotations, None, True), + dry_run=(dry_run, None), + git_remote_sha=(git_remote_sha, None), + git_remote_branch=(git_remote_branch, None), + git_remote_url=(git_remote_url, None), + git_root=(git_root, None, optional_path), + published_by=(published_by, None), + log_type=(log_type, "console"), + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ) + results: list[dict[str, Any]] = [] + for args in all_args: + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + client = None if args.dry_run else _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, base_url), + command_name="published-component commands", + ) + publisher = ComponentPublisher( + dry_run=bool(args.dry_run), + git_remote_sha=args.git_remote_sha, + git_remote_branch=args.git_remote_branch, + git_remote_url=args.git_remote_url, + git_root=args.git_root, + published_by=args.published_by, + client=client, + logger=logger, + ) + result = publisher.publish_component( + args.component_path, + image=args.image, + name=args.name, + description=args.description, + annotations=args.annotations, + ) + result_dict = result.to_dict() if hasattr(result, "to_dict") else dict(result) + results.append({"component_path": str(args.component_path), **result_dict}) + finally: + finalize_logs() + + error_count = sum(1 for result in results if result.get("status") in {"error", "failed"}) + summary = { + "status": "failed" if error_count else "success", + "components_count": len(results), + "error_count": error_count, + "results": results, + } + print_json(summary) + if error_count: + raise SystemExit(1) + + +@app.command(name="deprecate") +def published_components_deprecate( + digest: str | None = None, + *, + superseded_by: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Deprecate a published component by digest.""" + + for args in load_args_or_exit( + config, + digest=("digest", digest, None, False, True), + superseded_by=(superseded_by, None), + log_type=(log_type, "console"), + **api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, base_url), + command_name="published-component commands", + ) + result = deprecate_component( + client, + args.digest, + superseded_by=args.superseded_by, + logger=logger, + ) + result_dict = result.to_dict() if hasattr(result, "to_dict") else result + print_json(result_dict) + if isinstance(result_dict, dict) and not result_dict.get("success", result_dict.get("status") != "failed"): + raise SystemExit(1) + finally: + finalize_logs() diff --git a/packages/tangle-cli/src/tangle_cli/py.typed b/packages/tangle-cli/src/tangle_cli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/tangle-cli/src/tangle_cli/quickstart.py b/packages/tangle-cli/src/tangle_cli/quickstart.py new file mode 100644 index 0000000..2f38060 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/quickstart.py @@ -0,0 +1,110 @@ +"""Native-free quickstart text for the root ``tangle`` CLI.""" + +from __future__ import annotations + +from textwrap import dedent + +from cyclopts import App + + +app = App(name="quickstart", help="Print a concise native-free guide to the Tangle CLI.") + + +QUICKSTART_TEXT = dedent( + """ + Tangle CLI quickstart + ===================== + + Command families + ---------------- + tangle api ... + Pure OpenAPI wrappers around a Tangle API. Commands are generated from + the checked-in official schema and can be extended from a live backend + schema cache. Use these when you want a direct backend endpoint call. + + tangle sdk ... + Hand-written SDK commands for local workflows and compound operations. + Some commands are local-only (for example pipeline validation/layout and + component generation); others call the API through the generated client + while adding domain behavior such as hydration, submit payload shaping, + version checks, or config batching. + + Common flags and environment + ---------------------------- + API-backed commands commonly accept: + --base-url URL API base URL (or TANGLE_API_URL) + --token TOKEN bearer token (or TANGLE_API_TOKEN) + --auth-header VALUE full Authorization value, e.g. 'Basic ...' or + 'Bearer ...' (or TANGLE_API_AUTH_HEADER / + TANGLE_AUTH_HEADER) + -H, --header 'N: V' extra headers; repeatable (or TANGLE_API_HEADERS) + --config PATH YAML/JSON defaults; CLI values win over config + --log-type TYPE progress logs: console, none, file (SDK commands) + + TANGLE_VERBOSE=1 enables redacted HTTP request/response diagnostics on + stderr. It is separate from normal progress logging and should not be + required for routine hydration/publish progress. + + Protected API examples + ---------------------- + tangle api refresh --base-url https://api.example \\ + --auth-header 'Bearer ...' -H 'X-Gateway-Auth: ...' + + tangle api pipeline-runs list --base-url https://api.example \\ + --auth-header 'Basic ...' -H 'X-Api-Key: ...' + + tangle sdk pipeline-runs submit pipeline.yaml --base-url https://api.example \\ + --auth-header 'Bearer ...' --log-type console + + Local SDK examples + ------------------ + tangle sdk pipelines validate pipeline.yaml + tangle sdk pipelines hydrate pipeline.yaml --output hydrated.yaml + tangle sdk components generate from-python component.py --image python:3.12 + tangle sdk components bump-version component.yaml + + API-backed SDK examples + ----------------------- + tangle sdk published-components search transformer --base-url https://api.example + tangle sdk published-components publish component.yaml --dry-run + tangle sdk pipeline-runs submit pipeline.yaml --dry-run --log-type none + tangle sdk pipeline-runs status RUN_ID --base-url https://api.example + + Generated vs hand-written packages + ---------------------------------- + tangle_cli is the hand-written package: CLI wiring, local SDK workflows, + 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. + + 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. + + Discover more + ------------- + tangle --help + tangle api --help + tangle api refresh --help + tangle sdk --help + tangle sdk pipelines --help + tangle sdk pipeline-runs submit --help + + See README.md for codegen/autogen instructions and extension surfaces: + hydrator resolvers, PipelineRunHooks, ComponentPublishHook, and shared CLI + options/logging helpers. + """ +).strip() + + +@app.default +def quickstart() -> None: + """Print a concise native-free guide to the Tangle CLI.""" + + print(QUICKSTART_TEXT) diff --git a/packages/tangle-cli/src/tangle_cli/secrets.py b/packages/tangle-cli/src/tangle_cli/secrets.py new file mode 100644 index 0000000..bfb87d4 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/secrets.py @@ -0,0 +1,156 @@ +"""Read/write helpers for Tangle secret metadata and values. + +Secret values are accepted only for explicit create/update operations and are +never included in returned metadata dictionaries. +""" + +from __future__ import annotations + +import os +from typing import Any, Protocol + +from .handler import TangleCliHandler + + +class SecretClient(Protocol): + """Subset of the generated static client used by secret commands.""" + + def secrets_list(self) -> Any: ... + + def secrets_create( + self, + secret_name: str, + secret_value: str, + description: str | None = None, + expires_at: str | None = None, + ) -> Any: ... + + def secrets_update( + self, + secret_name: str, + secret_value: str, + description: str | None = None, + expires_at: str | None = None, + ) -> Any: ... + + def secrets_delete(self, secret_name: str) -> Any: ... + + +class SecretValueError(ValueError): + """Raised when secret value CLI/config inputs are invalid.""" + + +class SecretsManager(TangleCliHandler): + """Secret resource manager with injectable client construction. + + Downstream packages can inject an authenticated client directly or provide a + lazy ``client_factory``. Returned dictionaries intentionally omit secret + values and only include metadata. + """ + + def __init__( + self, + client: SecretClient | None = None, + *, + client_factory: Any | None = None, + logger: Any | None = None, + **kwargs: Any, + ) -> None: + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) + + @staticmethod + def resolve_secret_value(value: str | None, from_env: str | None) -> str: + """Resolve the secret value from either ``--value`` or ``--from-env``. + + Error messages intentionally mention only the option/env-var name and + never include the secret value. + """ + + if value is not None and from_env is not None: + raise SecretValueError("specify either --value or --from-env, not both") + if from_env is not None: + resolved = os.environ.get(from_env) + if resolved is None: + raise SecretValueError(f"environment variable '{from_env}' is not set") + return resolved + if value is not None: + return value + raise SecretValueError("either --value or --from-env is required") + + @staticmethod + def secret_metadata(secret: Any) -> dict[str, Any]: + """Return JSON-safe secret metadata, excluding any secret value fields.""" + + entry: dict[str, Any] = {} + for field in ("secret_name", "created_at", "updated_at", "expires_at", "description"): + value = _value_from_mapping_or_object(secret, field) + if value is not None: + entry[field] = str(value) + return entry + + def list(self) -> dict[str, Any]: + """List secret metadata without exposing secret values.""" + + response = self._require_client().secrets_list() + raw_secrets = _value_from_mapping_or_object(response, "secrets", []) or [] + secrets = [self.secret_metadata(secret) for secret in raw_secrets] + return {"status": "success", "count": len(secrets), "secrets": secrets} + + def create( + self, + secret_name: str, + *, + value: str | None = None, + from_env: str | None = None, + description: str | None = None, + expires_at: str | None = None, + ) -> dict[str, Any]: + """Create a secret using generated static API operations.""" + + secret_value = self.resolve_secret_value(value, from_env) + secret = self._require_client().secrets_create( + secret_name, + secret_value, + description=description, + expires_at=expires_at, + ) + return {"status": "success", "action": "created", "secret": self.secret_metadata(secret)} + + def update( + self, + secret_name: str, + *, + value: str | None = None, + from_env: str | None = None, + description: str | None = None, + expires_at: str | None = None, + ) -> dict[str, Any]: + """Update a secret using generated static API operations.""" + + secret_value = self.resolve_secret_value(value, from_env) + secret = self._require_client().secrets_update( + secret_name, + secret_value, + description=description, + expires_at=expires_at, + ) + return {"status": "success", "action": "updated", "secret": self.secret_metadata(secret)} + + def delete(self, secret_name: str) -> dict[str, Any]: + """Delete a secret using generated static API operations.""" + + self._require_client().secrets_delete(secret_name) + return {"status": "success", "action": "deleted", "secret_name": secret_name} + + +def _value_from_mapping_or_object(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) + + +__all__ = [ + "SecretClient", + "SecretValueError", + "SecretsManager", +] diff --git a/packages/tangle-cli/src/tangle_cli/secrets_cli.py b/packages/tangle-cli/src/tangle_cli/secrets_cli.py new file mode 100644 index 0000000..d6c2da1 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/secrets_cli.py @@ -0,0 +1,269 @@ +"""`tangle sdk secrets` command implementation.""" + +from __future__ import annotations + +import sys +from typing import Annotated, Any, Callable + +from cyclopts import App, Parameter + +from .args_container import ArgsContainer +from .cli_helpers import ( + LazyTangleApiClient, + api_arg_specs, + include_env_credentials_for_args, + load_args_or_exit, + print_json, +) +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + LogTypeOption, + TokenOption, +) +from .logger import Logger, logger_for_log_type +from .secrets import SecretsManager, SecretValueError + +ValueOption = Annotated[ + str | None, + Parameter( + name="--value", + alias="-v", + help="Secret value. Prefer --from-env to avoid shell history exposure.", + ), +] +FromEnvOption = Annotated[ + str | None, + Parameter( + name="--from-env", + alias="-e", + help="Read secret value from this environment variable.", + ), +] +DescriptionOption = Annotated[ + str | None, + Parameter(name="--description", alias="-d", help="Secret description."), +] +ExpiresAtOption = Annotated[ + str | None, + Parameter(help="Expiration datetime (ISO 8601)."), +] +ForceOption = Annotated[ + bool, + Parameter(help="Skip confirmation prompt."), +] + +app = App(name="secrets", help="Manage Tangle secrets.") + + +def _client(args: ArgsContainer, *, cli_base_url: str | None, command_name: str) -> LazyTangleApiClient: + return LazyTangleApiClient( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + include_env_credentials=include_env_credentials_for_args(args, cli_base_url), + command_name=command_name, + ) + + +def _run_secret_action( + config: str | None, + cli_base_url: str | None, + specs: dict[str, tuple[Any, ...]], + fn: Callable[[Any, ArgsContainer, Logger], dict[str, Any]], +) -> None: + results: list[dict[str, Any]] = [] + for args in load_args_or_exit(config, **specs): + logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console")) + try: + client = _client(args, cli_base_url=cli_base_url, command_name="secret commands") + try: + results.append(fn(client, args, logger)) + except SecretValueError as exc: + raise SystemExit(str(exc)) from exc + finally: + finalize_logs() + + print_json(results[0] if len(results) == 1 else {"status": "success", "results": results}) + + +def _secret_mutation_specs( + *, + secret_name: str | None, + value: str | None, + from_env: str | None, + description: str | None, + expires_at: str | None, + base_url: str | None, + token: str | None, + auth_header: str | None, + header: list[str] | None, + log_type: str, +) -> dict[str, tuple[Any, ...]]: + return { + "secret_name": ("secret_name", secret_name, None, False, True), + "value": (value, None), + "from_env": (from_env, None), + "description": (description, None), + "expires_at": (expires_at, None), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + +def _confirm_delete(secret_name: str) -> None: + prompt = f"Are you sure you want to delete secret '{secret_name}'? [y/N]: " + print(prompt, end="", file=sys.stderr, flush=True) + try: + response = input() + except EOFError as exc: # pragma: no cover - defensive for non-interactive shells + raise SystemExit("Delete cancelled") from exc + if response.strip().lower() not in {"y", "yes"}: + raise SystemExit("Delete cancelled") + + +@app.command(name="list") +def secrets_list( + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """List secret metadata. Secret values are never returned.""" + + specs = { + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: + result = SecretsManager(client=client).list() + logger.info(f"Listed {result['count']} secret(s).") + return result + + _run_secret_action(config, base_url, specs, action) + + +@app.command(name="create") +def secrets_create( + secret_name: str | None = None, + *, + value: ValueOption = None, + from_env: FromEnvOption = None, + description: DescriptionOption = None, + expires_at: ExpiresAtOption = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Create a new secret.""" + + specs = _secret_mutation_specs( + secret_name=secret_name, + value=value, + from_env=from_env, + description=description, + expires_at=expires_at, + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + log_type=log_type, + ) + + def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: + result = SecretsManager(client=client).create( + args.secret_name, + value=args.value, + from_env=args.from_env, + description=args.description, + expires_at=args.expires_at, + ) + logger.info(f"Created secret: {args.secret_name}") + return result + + _run_secret_action(config, base_url, specs, action) + + +@app.command(name="update") +def secrets_update( + secret_name: str | None = None, + *, + value: ValueOption = None, + from_env: FromEnvOption = None, + description: DescriptionOption = None, + expires_at: ExpiresAtOption = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Update an existing secret.""" + + specs = _secret_mutation_specs( + secret_name=secret_name, + value=value, + from_env=from_env, + description=description, + expires_at=expires_at, + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + log_type=log_type, + ) + + def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: + result = SecretsManager(client=client).update( + args.secret_name, + value=args.value, + from_env=args.from_env, + description=args.description, + expires_at=args.expires_at, + ) + logger.info(f"Updated secret: {args.secret_name}") + return result + + _run_secret_action(config, base_url, specs, action) + + +@app.command(name="delete") +def secrets_delete( + secret_name: str | None = None, + *, + force: ForceOption = False, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, + log_type: LogTypeOption = "console", +) -> None: + """Delete a secret. Prompts for confirmation unless ``--force`` is used.""" + + specs = { + "secret_name": ("secret_name", secret_name, None, False, True), + "force": (force, False), + "log_type": (log_type, "console"), + **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), + } + + def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: + if not args.force: + _confirm_delete(args.secret_name) + result = SecretsManager(client=client).delete(args.secret_name) + logger.info(f"Deleted secret: {args.secret_name}") + return result + + _run_secret_action(config, base_url, specs, action) diff --git a/packages/tangle-cli/src/tangle_cli/utils.py b/packages/tangle-cli/src/tangle_cli/utils.py new file mode 100644 index 0000000..0449d45 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/utils.py @@ -0,0 +1,942 @@ +""" +Generic utility functions for tangle-cli. + +YAML parsing/dumping, version comparison, digest computation, git metadata +extraction, and pipeline-spec traversal. +""" + +import hashlib +import os +import re +import subprocess +from collections import OrderedDict +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +import yaml + +from tangle_cli.logger import Logger, get_default_logger + +# ============================================================================= +# Generic Data Helpers +# ============================================================================= + + +def _strip_text_from_graph(implementation: dict[str, Any]) -> None: + """Recursively remove raw component text from graph component references.""" + + graph = implementation.get("graph", {}) + for task_data in graph.get("tasks", {}).values(): + ref = task_data.get("componentRef") + if not ref: + continue + ref.pop("text", None) + spec = ref.get("spec", {}) + nested_impl = spec.get("implementation") + if nested_impl and "graph" in nested_impl: + _strip_text_from_graph(nested_impl) + + +def add_official_prefix(name: str | None) -> str | None: + """Return the official component name variant used by registry searches.""" + + if name and not name.startswith("[Official]"): + return f"[Official] {name}" + return name + + +def _value_from_mapping_or_object(value: object, key: str, default: Any = None) -> Any: + """Read a field from a mapping, generated model, or attribute object.""" + + if isinstance(value, Mapping): + return value.get(key, default) + + get = getattr(value, "get", None) + if callable(get): + return get(key, default) + + to_dict = getattr(value, "to_dict", None) + if callable(to_dict): + data = to_dict() + if isinstance(data, Mapping): + return data.get(key, default) + + return getattr(value, key, default) + + +def _optional_str(value: Any) -> str | None: + """Return *value* only when it is already a string.""" + + return value if isinstance(value, str) else None + + +# ============================================================================= +# Numeric Helpers +# ============================================================================= + + +def clamp(value: float, lower: float, upper: float) -> float: + """Return value bounded to the inclusive ``[lower, upper]`` range.""" + return min(max(value, lower), upper) + + +# ============================================================================= +# Environment Helpers +# ============================================================================= + +# Values accepted as truthy for boolean-style env vars across Tangle tooling. +_TRUTHY_ENV_VALUES = ("1", "true", "yes") + + +def tangle_verbose_enabled() -> bool: + """Return True if the ``TANGLE_VERBOSE`` env var is set to a truthy value. + + Truthy values (case-insensitive): ``"1"``, ``"true"``, ``"yes"``. This is + the canonical check used by the API client, publisher, and hydrator so + that verbose-only diagnostics behave consistently across the codebase. + """ + return os.environ.get("TANGLE_VERBOSE", "").lower() in _TRUTHY_ENV_VALUES + + +# ============================================================================= +# Component-Path Conventions +# ============================================================================= + + +def find_documentation_path_for_yaml(yaml_path: Path) -> str | None: + """Return ``docs/.md`` next to a component YAML, if it exists. + + Encodes the convention that a component YAML at ``foo/bar.yaml`` carries + its human-readable docs at ``foo/docs/bar.md``. Returns the absolute + path as a string, or ``None`` when no such file exists. + """ + docs_path = yaml_path.parent / "docs" / f"{yaml_path.stem}.md" + return str(docs_path.resolve()) if docs_path.exists() else None + + +# ============================================================================= +# String / Template Helpers +# ============================================================================= + +# Recognizes ``${name}`` or ``${name:-default}`` placeholders. The syntax +# is borrowed from POSIX parameter expansion for familiarity, but these +# placeholders have nothing to do with shells, processes, or environments +# — they're filled from an explicit ``vars`` dict, never from +# ``os.environ``. ``name`` follows Python identifier rules (letter or +# underscore start, then alphanumerics / underscores). ``default`` is +# everything up to the closing ``}`` and may be empty (``${name:-}``). +# +# Convention: prefer lowercase / snake_case ``name``s. Uppercase reads as +# an env-var reference and risks misleading readers about what's actually +# providing the values. +_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + + +class UnsetVarError(KeyError): + """Raised when a strict ``${name}`` placeholder has no value and no default. + + A ``KeyError`` subclass so existing ``except KeyError`` handlers keep + working; the dedicated type lets callers distinguish unresolved + placeholders from incidental ``KeyError``s if they want a clearer + error message. + """ + + +def expand_vars(text: str, vars: dict[str, str]) -> str: + """Expand ``${name}`` / ``${name:-default}`` placeholders in ``text``. + + Mirrors ``os.path.expandvars`` in syntax, but reads from an explicit + ``vars`` dict instead of ``os.environ`` — these are *not* environment + variables, despite the syntax similarity. Lowercase / snake_case + names are conventional here (uppercase would mislead readers who treat + the same syntax as env-var interpolation in shells/Docker/etc.). + Recognized forms: + + * ``${name}`` — strict; raises :class:`UnsetVarError` (a ``KeyError`` + subclass) if ``name`` is missing from ``vars``. + * ``${name:-default}`` — falls back to the literal ``default`` text when + ``name`` is missing. ``${name:-}`` substitutes the empty string. + + Substitution is purely textual; values are inserted verbatim. Callers + that interpolate into structured formats (YAML, JSON, shell commands, + …) should quote the placeholder appropriately so unusual values can't + break the surrounding syntax — e.g. for YAML, write + ``image: "${image:-}"`` so a value beginning with ``*`` doesn't get + parsed as an alias reference. + + Args: + text: The text containing zero or more placeholders. + vars: Flat ``{name: stringified_value}`` map. Empty/None falls back + to a no-op when no placeholders are present in ``text``. + + Returns: + ``text`` with every recognized placeholder replaced. + + Raises: + UnsetVarError: A strict ``${name}`` placeholder had no + corresponding entry in ``vars``. + """ + if not vars and "${" not in text: + return text + + def _replace(m: re.Match[str]) -> str: + name = m.group(1) + default = m.group(2) + if name in vars: + return vars[name] + if default is not None: + return default + raise UnsetVarError(name) + + return _VAR_RE.sub(_replace, text) + + +def resolve_input_path(path: Path, config_dir: Path | None) -> Path: + """Resolve a relative input path by trying cwd first, then the config directory. + + Used to make config file entries portable: a relative input path like + ``pipelines/foo.yaml`` is tried against the cwd first (preserving existing + behavior), then against the config file's directory as a fallback. + + Args: + path: Input path to resolve. + config_dir: Directory of the config file. If ``None``, path is returned unchanged. + + Returns: + The resolved absolute path, or the original path if nothing matched. + """ + if config_dir is None or path.is_absolute() or path.exists(): + return path + candidate = config_dir / path + return candidate.resolve() if candidate.exists() else path + + +# ============================================================================= +# Dict merge helpers +# ============================================================================= + + +def apply_defaults( + entries: dict[str, Any] | list[dict[str, Any]], + defaults: dict[str, Any], +) -> dict[str, Any] | list[dict[str, Any]]: + """Shallow-merge *defaults* into *entries* (entry values take precedence). + + Works on a single dict, a list of dicts, or a dict-of-dicts (keyed entries). + For a dict-of-dicts, keys starting with ``_`` are excluded from merging + (they are metadata like ``_defaults`` itself). + + Args: + entries: The entries to merge defaults into. + defaults: Default values (overridden by entry values). + + Returns: + Merged result in the same shape as *entries*. + """ + if isinstance(entries, list): + return [{**defaults, **item} if isinstance(item, dict) else item for item in entries] + return {**defaults, **entries} + + +# ============================================================================= +# Digest Utilities +# ============================================================================= + + +def compute_text_digest(text: str) -> str: + """Compute a SHA256 digest from raw text. + + Args: + text: The text to hash. + + Returns: + Hex digest string. + """ + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def compute_spec_digest(spec: dict[str, Any]) -> str: + """Compute a SHA256 digest for a component spec. + + Args: + spec: The component spec dict. + + Returns: + Hex digest string. + """ + # Serialize spec to YAML with sorted keys for deterministic output + yaml_str = dump_yaml(spec, sort_keys=True) + return compute_text_digest(yaml_str) + + +# Type alias for task processor callback +# Receives (task_name, task_data, path, base_dir) and returns processed task_data. +TaskProcessor = Callable[[str, dict[str, Any], str, Path | None, dict[str, Any] | None], dict[str, Any]] + + +def is_subgraph_spec(spec: dict[str, Any] | None) -> bool: + """Check if a spec contains a subgraph (has implementation.graph).""" + if not spec: + return False + return "graph" in spec.get("implementation", {}) + + +def is_graph_task(task_data: dict[str, Any]) -> bool: + """Check if a task has a componentRef that is a subgraph. + + Args: + task_data: The task dict to check. + + Returns: + True if the task has a componentRef with nested implementation.graph. + """ + component_ref = task_data.get("componentRef") + if not isinstance(component_ref, dict): + return False + return is_subgraph_spec(component_ref.get("spec", {})) + + +def get_component_ref_info(component_ref: dict[str, Any]) -> tuple[str, str]: + """Extract name and digest from a componentRef. + + Args: + component_ref: The componentRef dict (must have spec.name and digest). + + Returns: + Tuple of (name, digest). + """ + name = component_ref.get("spec", {}).get("name", "unknown") + digest = component_ref.get("digest", "unknown") + return name, digest + + +def _strip_internal_annotations(spec: dict[str, Any]) -> None: + """Remove all internal underscore-prefixed keys from a spec dict. + + These keys (e.g. ``_source_dir``, ``_recursive_params``) are used during + traversal and must not leak into the final output. + """ + for key in [k for k in spec if k.startswith("_")]: + del spec[key] + + +def _extract_source_dir(spec: dict[str, Any], fallback: Path | None) -> Path | None: + """Extract and remove _source_dir annotation from a spec. + + When a component is loaded from a local file, _source_dir is set to the + directory containing that file. This allows nested file:// references to + be resolved relative to the file they appear in, not the top-level pipeline. + """ + source_dir = spec.pop("_source_dir", None) + if source_dir is not None: + return Path(source_dir) + return fallback + + +def _extract_recursive_params( + spec: dict[str, Any], fallback: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Extract and remove _recursive_params annotation from a spec. + + When recursive context is active, _recursive_params carries the accumulated + template parameters for this subtree. Works like _source_dir: the value is + consumed here and threaded through the recursive traversal. + """ + return spec.pop("_recursive_params", fallback) + + +def traverse_pipeline_tasks( + spec: dict[str, Any], + parent_name: str, + task_processor: TaskProcessor, + base_dir: Path | None = None, + recursive_params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Traverse a pipeline/component spec and process each task recursively. + + This function walks through implementation.graph.tasks. For each task: + - If it's a subgraph (has componentRef with nested graph), recurse into it without processing + - Otherwise, call task_processor to handle the task + + When a nested spec has a '_source_dir' annotation (set when a component was + loaded from a local file), the base_dir is updated for that subtree so that + nested file:// references resolve relative to the loaded file. + + Similarly, '_recursive_params' carries accumulated template parameters for + recursive context propagation. Like _source_dir, the value is extracted from + specs at recursion boundaries and threaded through to the task processor. + + Args: + spec: The component/pipeline spec with implementation.graph.tasks structure. + parent_name: Name prefix for path display (e.g., pipeline name). + task_processor: Callback to process non-subgraph tasks. + Receives (task_name, task_data, path, base_dir, recursive_params) + and returns the processed task dict. + base_dir: Base directory for resolving relative file paths. Updated + automatically when entering specs loaded from local files + (via _source_dir annotation). + recursive_params: Accumulated template parameters for recursive context. + Updated automatically when entering specs with + _recursive_params annotation. + + Returns: + The spec with all tasks processed (including nested subgraph tasks). + """ + implementation = spec.get("implementation", {}) + graph = implementation.get("graph", {}) + tasks = graph.get("tasks", {}) + + if not tasks: + return spec + + processed_tasks = {} + for task_name, task_data in tasks.items(): + path = f"{parent_name}.{task_name}" if parent_name else task_name + + # If task is a subgraph, recurse into it without processing + if is_graph_task(task_data): + component_ref = task_data["componentRef"] + nested_spec = component_ref.get("spec", {}) + nested_name = component_ref.get("name", task_name) + nested_base_dir = _extract_source_dir(nested_spec, base_dir) + nested_params = _extract_recursive_params(nested_spec, recursive_params) + + resolved_nested_spec = traverse_pipeline_tasks( + nested_spec, nested_name, task_processor, nested_base_dir, nested_params + ) + _strip_internal_annotations(resolved_nested_spec) + + if resolved_nested_spec != nested_spec: + processed_task = dict(task_data) + # Use spec name as fallback, compute digest if not present + new_ref = { + "name": component_ref.get("name") or nested_spec.get("name", ""), + "digest": component_ref.get("digest") or compute_spec_digest(resolved_nested_spec), + "spec": resolved_nested_spec, + } + processed_task["componentRef"] = new_ref + else: + processed_task = task_data + else: + # Process non-subgraph tasks, passing current base_dir and recursive params + processed_task = task_processor(task_name, task_data, path, base_dir, recursive_params) + + # If processing created a subgraph, recurse into it + if is_graph_task(processed_task): + component_ref = processed_task["componentRef"] + nested_spec = component_ref.get("spec", {}) + nested_name = component_ref.get("name", task_name) + nested_base_dir = _extract_source_dir(nested_spec, base_dir) + nested_params = _extract_recursive_params(nested_spec, recursive_params) + + resolved_nested_spec = traverse_pipeline_tasks( + nested_spec, nested_name, task_processor, nested_base_dir, nested_params + ) + _strip_internal_annotations(resolved_nested_spec) + + if resolved_nested_spec != nested_spec: + processed_task = dict(processed_task) + # Use spec name as fallback, compute digest if not present + new_ref = { + "name": component_ref.get("name") or nested_spec.get("name", ""), + "digest": component_ref.get("digest") or compute_spec_digest(resolved_nested_spec), + "spec": resolved_nested_spec, + } + processed_task["componentRef"] = new_ref + else: + # Strip internal annotations from non-subgraph specs (no nested tasks to resolve) + cr = processed_task.get("componentRef") + if isinstance(cr, dict): + s = cr.get("spec") + if isinstance(s, dict): + _strip_internal_annotations(s) + + processed_tasks[task_name] = processed_task + + # Rebuild the spec with processed tasks + result = dict(spec) + result["implementation"] = dict(implementation) + result["implementation"]["graph"] = dict(graph) + result["implementation"]["graph"]["tasks"] = processed_tasks + return result + + +def parse_yaml_string(yaml_content, logger: Logger | None = None): + """ + Parse a YAML string into a data structure. + + Args: + yaml_content: YAML string content + + Returns: + Parsed data structure or None if parsing fails + """ + log = logger or get_default_logger() + + # Setup YAML to properly handle OrderedDict and compact lists + def represent_ordereddict(dumper, data): + return dumper.represent_dict(data.items()) + + yaml.add_representer(OrderedDict, represent_ordereddict) + + try: + return yaml.safe_load(yaml_content) + except Exception as e: + import traceback + log.error(f"YAML parsing error: {e}") + log.error(f"Traceback: {traceback.format_exc()}") + return None + + +class _LiteralBlockDumper(yaml.SafeDumper): + """YAML dumper that uses literal block style (|) for multiline strings.""" + pass + + +def _literal_str_representer(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode: + if '\n' in data: + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') + return dumper.represent_scalar('tag:yaml.org,2002:str', data) + + +_LiteralBlockDumper.add_representer(str, _literal_str_representer) + + +def dump_yaml(data: dict[str, Any], sort_keys: bool = False, width: int | None = None) -> str: + """ + Dump a data structure to a YAML string with consistent formatting. + + Multiline strings are rendered using literal block style (|). + + Args: + data: Dictionary to serialize to YAML + sort_keys: Whether to sort dictionary keys (default: False) + width: Line width limit (default: None, no limit) + + Returns: + YAML string + """ + return yaml.dump( + data, Dumper=_LiteralBlockDumper, + default_flow_style=False, sort_keys=sort_keys, allow_unicode=True, width=width, + ) + + +def get_version_from_data(data): + """ + Extract version from a data dictionary (parsed YAML structure). + + Checks metadata.annotations.version first (preferred), then falls back + to top-level version for backward compatibility. + + Args: + data: Dictionary containing the parsed YAML structure + + Returns: + Version string or None if not found + """ + if not data: + return None + + # Check metadata.annotations.version first (preferred location) + metadata = data.get('metadata') + if metadata: + annotations = metadata.get('annotations') + if annotations and 'version' in annotations: + return str(annotations['version']) + + # Fall back to top-level version for backward compatibility + if 'version' in data: + return str(data['version']) + + return None + + +def get_version_component(parts, index, default=0): + """ + Get version component at index as int, or default if not parseable. + + Args: + parts: List of version components + index: Index to retrieve + default: Default value if component is missing or not numeric + + Returns: + Integer version component or default + """ + try: + return int(parts[index]) if index < len(parts) else default + except (ValueError, TypeError, IndexError): + return default + + +def compare_versions(a: str, b: str) -> int: + """Compare two version strings component-wise, returning -1, 0, or 1. + + Unlike :func:`check_versions`, this pads the shorter version with + zeros so that ``1.0.1`` is correctly greater than ``1.0``. + + Args: + a: First version string (e.g. "1.2.3"). + b: Second version string (e.g. "1.2"). + + Returns: + -1 if a < b, 0 if a == b, 1 if a > b. + """ + a_parts = a.split(".") + b_parts = b.split(".") + length = max(len(a_parts), len(b_parts)) + for i in range(length): + a_val = get_version_component(a_parts, i) + b_val = get_version_component(b_parts, i) + if a_val > b_val: + return 1 + if a_val < b_val: + return -1 + return 0 + + +def check_versions(local_version, latest_version, check_precedence=False): + """Check if a version update should proceed. + + Thin wrapper around :func:`compare_versions` for backward compatibility. + + Args: + local_version: The local version string. + latest_version: The latest published version (or None if not found). + check_precedence: If True, return True only when *local* is strictly + newer. If False (default), return True when versions differ. + + Returns: + bool: True if should proceed with update, False if should skip. + """ + if not latest_version: + return True + + cmp = compare_versions(local_version, latest_version) + + if check_precedence: + return cmp > 0 + return cmp != 0 + + +# ============================================================================= +# Git info collection +# ============================================================================= + + +def get_git_root(directory: Path) -> Path | None: + """Find the git repository root for a directory.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + return None + + +def get_git_info(directory: Path, logger: Logger | None = None) -> dict[str, str]: + """Collect git metadata for annotations. + + Uses subprocess git commands to avoid requiring gitpython. + The returned dict includes a ``_git_root`` key (absolute path to the + repository root) so callers can compute relative paths without a + second subprocess call. This key is prefixed with ``_`` to signal + it is not a component annotation and should not be persisted. + """ + info: dict[str, str] = {} + + try: + # Find git root + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0: + if logger: + stderr = result.stderr.strip() if result.stderr else "unknown reason" + logger.warn(f"⚠️ Not a git repository ({stderr}). " + "Will try CI environment variables.") + else: + git_root = Path(result.stdout.strip()) + info["_git_root"] = str(git_root) + + # git_relative_dir + try: + rel_dir = directory.resolve().relative_to(git_root) + info["git_relative_dir"] = rel_dir.as_posix() + except ValueError: + pass + + # git_local_branch + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + info["git_local_branch"] = result.stdout.strip() + + # git_local_sha + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + info["git_local_sha"] = result.stdout.strip() + + # Tracking branch info + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + tracking = result.stdout.strip() # e.g., "origin/main" + parts = tracking.split("/", 1) + if len(parts) == 2: + remote_name, remote_branch = parts + info["git_remote_branch"] = remote_branch + + # Remote URL + result = subprocess.run( + ["git", "remote", "get-url", remote_name], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + info["git_remote_url"] = result.stdout.strip() + + # Remote SHA + result = subprocess.run( + ["git", "rev-parse", tracking], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + info["git_remote_sha"] = result.stdout.strip() + + # Fallback: if no tracking branch, use local sha/branch and origin URL + if "git_remote_url" not in info: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=str(directory), capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + info["git_remote_url"] = result.stdout.strip() + if "git_remote_sha" not in info and "git_local_sha" in info: + info["git_remote_sha"] = info["git_local_sha"] + if "git_remote_branch" not in info and "git_local_branch" in info: + info["git_remote_branch"] = info["git_local_branch"] + + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + if logger: + logger.warn(f"⚠️ Git not available ({type(e).__name__}: {e}). " + "Will try CI environment variables.") + + # Fallback: populate missing fields from CI environment variables + _fill_from_ci_env(info) + + # Normalize SSH git URLs to HTTPS (e.g. git@github.com:Org/repo.git -> https://github.com/Org/repo.git) + if "git_remote_url" in info: + info["git_remote_url"] = _normalize_git_url(info["git_remote_url"]) + + # Log resolved git metadata and warn about missing fields + if logger: + logger.info(" Git metadata resolved:") + logger.info(f" _git_root: {info.get('_git_root', '(not set)')}") + logger.info(f" git_remote_sha: {info.get('git_remote_sha', '(not set)')}") + logger.info(f" git_remote_branch: {info.get('git_remote_branch', '(not set)')}") + logger.info(f" git_remote_url: {info.get('git_remote_url', '(not set)')}") + + missing = [] + if "_git_root" not in info: + missing.append("git_root (needed for component_yaml_path)") + if "git_remote_url" not in info: + missing.append("git_remote_url") + if "git_remote_sha" not in info: + missing.append("git_remote_sha") + if "git_remote_branch" not in info: + missing.append("git_remote_branch") + if missing: + logger.warn( + f"⚠️ Missing git metadata: {', '.join(missing)}. " + "Published components will lack source links and transparency signals. " + "Pass --git-remote-sha/--git-remote-branch/--git-remote-url or run from a git repo." + ) + + return info + + +def set_component_yaml_path(rel_path: str, annotations: dict[str, str], *, overwrite: bool = True) -> None: + """Split a repo-relative path into git_relative_dir and component_yaml_path annotations. + + Given ``"a/b/comp.yaml"``, sets ``git_relative_dir="a/b"`` and + ``component_yaml_path="comp.yaml"``. For a bare filename like + ``"comp.yaml"``, only ``component_yaml_path`` is set. + + Args: + overwrite: If False, preserve existing values (setdefault semantics). + """ + parts = rel_path.rsplit("/", 1) + if overwrite: + if len(parts) == 2: + annotations["git_relative_dir"] = parts[0] + annotations["component_yaml_path"] = parts[1] + else: + annotations["component_yaml_path"] = rel_path + else: + if len(parts) == 2: + annotations.setdefault("git_relative_dir", parts[0]) + annotations.setdefault("component_yaml_path", parts[1]) + else: + annotations.setdefault("component_yaml_path", rel_path) + + +def normalize_annotation_paths( + yaml_path: "str | Path", + git_root: "str | Path", + annotations: dict[str, str], +) -> None: + """Normalize ``dockerfile_path`` and ``documentation_path`` to be relative to ``git_relative_dir``. + + Component authors may write path annotations relative to the YAML file's + directory (e.g. ``../../../../dockerfiles/foo.Dockerfile``) or relative to + ``git_relative_dir`` (e.g. ``dockerfiles/foo.Dockerfile``). This function + resolves each path using filesystem checks and re-expresses it relative to + the final ``git_relative_dir``. + + Resolution order for each path annotation: + + 1. Relative to ``git_relative_dir`` — if the file exists, leave the value + as-is (already correct). + 2. Relative to the YAML file's parent directory — if the file exists, + re-express it relative to ``git_relative_dir``. + 3. If neither resolves to an existing file, leave the value unchanged. + + This is a no-op when ``git_relative_dir`` equals the YAML file's parent + directory (the common case). + + Args: + yaml_path: Filesystem path to the component YAML file. + git_root: Filesystem path to the git repository root. + annotations: The ``metadata.annotations`` dict (modified in place). + """ + import os + from pathlib import Path as _Path + + git_relative_dir = annotations.get("git_relative_dir") + if not git_relative_dir: + return + + git_root = _Path(git_root) + yaml_parent = _Path(yaml_path).resolve().parent + git_rel_dir_abs = (git_root / git_relative_dir).resolve() + + # If git_relative_dir resolves to the YAML parent, paths are equivalent — skip + if git_rel_dir_abs == yaml_parent: + return + + for key in ("dockerfile_path", "documentation_path"): + value = annotations.get(key) + if not value: + continue + + # 1. Already relative to git_relative_dir? + candidate_git = git_rel_dir_abs / value + if candidate_git.resolve().exists(): + continue # already correct + + # 2. Relative to YAML parent dir? + candidate_yaml = yaml_parent / value + if candidate_yaml.resolve().exists(): + # Re-express relative to git_relative_dir. Use os.path.relpath + # rather than Path.relative_to so that files *above* + # git_relative_dir produce ``../`` prefixed paths. + normalized = os.path.relpath( + str(candidate_yaml.resolve()), str(git_rel_dir_abs) + ) + annotations[key] = normalized + + +# CI environment variables probed for git metadata (checked in order, first +# match wins). Covers Buildkite, GitHub Actions, and GitLab CI out of the +# box. Wrapper packages can prepend additional CI-system-specific variables +# by monkey-patching these module attributes at import time. +_CI_GIT_ROOT_VARS: tuple[str, ...] = ("BUILDKITE_BUILD_CHECKOUT_PATH", "GITHUB_WORKSPACE", "CI_PROJECT_DIR") +_CI_SHA_VARS: tuple[str, ...] = ("BUILDKITE_COMMIT", "GITHUB_SHA", "CI_COMMIT_SHA") +_CI_BRANCH_VARS: tuple[str, ...] = ("BUILDKITE_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_BRANCH") +_CI_REPO_URL_VARS: tuple[str, ...] = ("BUILDKITE_REPO", "GITHUB_SERVER_URL", "CI_REPOSITORY_URL") + + +def _normalize_git_url(url: str) -> str: + """Normalize a git remote URL to a browsable HTTPS URL. + + Handles common formats: + - ``git@github.com:Org/repo.git`` -> ``https://github.com/Org/repo`` + - ``ssh://git@github.com/Org/repo.git`` -> ``https://github.com/Org/repo`` + - ``https://github.com/Org/repo.git`` -> ``https://github.com/Org/repo`` + - ``https://github.com/Org/repo`` -> unchanged + + The ``.git`` suffix is stripped so the result can be used directly to + build ``/blob/{ref}/{path}`` links without an extra ``.removesuffix``. + """ + import re + + # SCP-style: git@host:path + m = re.match(r"^git@([^:]+):(.+)$", url) + if m: + url = f"https://{m.group(1)}/{m.group(2)}" + else: + # ssh://git@host/path + m = re.match(r"^ssh://(?:[^@]+@)?([^/]+)/(.+)$", url) + if m: + url = f"https://{m.group(1)}/{m.group(2)}" + + return url.removesuffix(".git") + + +def _fill_from_ci_env(info: dict[str, str]) -> None: + """Fill missing git info fields from common CI environment variables. + + The env var lists are defined as module-level constants + (``_CI_GIT_ROOT_VARS``, ``_CI_SHA_VARS``, ``_CI_BRANCH_VARS``, + ``_CI_REPO_URL_VARS``) so they can be extended to support new CI systems. + """ + import os + + if "_git_root" not in info: + for var in _CI_GIT_ROOT_VARS: + val = os.environ.get(var) + if val: + info["_git_root"] = val + break + + if "git_remote_sha" not in info: + for var in _CI_SHA_VARS: + val = os.environ.get(var) + if val: + info["git_remote_sha"] = val + break + + if "git_remote_branch" not in info: + for var in _CI_BRANCH_VARS: + val = os.environ.get(var) + if val: + info["git_remote_branch"] = val + break + + if "git_remote_url" not in info: + for var in _CI_REPO_URL_VARS: + val = os.environ.get(var) + if val: + # GITHUB_SERVER_URL needs GITHUB_REPOSITORY appended + if var == "GITHUB_SERVER_URL": + repo = os.environ.get("GITHUB_REPOSITORY", "") + if repo: + val = f"{val}/{repo}" + else: + continue + info["git_remote_url"] = val + break diff --git a/packages/tangle-cli/src/tangle_cli/version_manager.py b/packages/tangle-cli/src/tangle_cli/version_manager.py new file mode 100644 index 0000000..0860889 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/version_manager.py @@ -0,0 +1,470 @@ +"""Version bumping for Tangle component YAML and Python source files.""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from tangle_cli import utils +from tangle_cli.component_from_func import extract_file_metadata, find_function_in_source +from tangle_cli.component_generator import ComponentGenerator +from tangle_cli.logger import Logger, get_default_logger + +ReferenceContentGetter = Callable[[str], str | None] + + +class VersionManager: + """Manage version updates for component YAML and Python source files.""" + + def __init__(self, logger: Logger | None = None) -> None: + self.log = logger or get_default_logger() + + def parse_version(self, version_str: str) -> tuple[int, ...]: + """Parse a major/minor[/patch] version string into integer parts.""" + + parts = str(version_str).strip().strip("\"'").split(".") + if len(parts) == 1: + return (int(parts[0]), 0) + if len(parts) == 2: + return (int(parts[0]), int(parts[1])) + return (int(parts[0]), int(parts[1]), int(parts[2])) + + def increment_version(self, version_str: str) -> str: + """Increment patch for x.y.z versions, otherwise increment minor.""" + + parts = self.parse_version(version_str) + if len(parts) == 3: + return f"{parts[0]}.{parts[1]}.{parts[2] + 1}" + return f"{parts[0]}.{parts[1] + 1}" + + def _get_yaml_version(self, content: str) -> str | None: + try: + data = yaml.safe_load(content) + return utils.get_version_from_data(data) + except Exception: + return None + + def update_yaml_file( + self, + file_path: str, + new_version: str | None = None, + reference_content_getter: ReferenceContentGetter | None = None, + update_timestamp: bool = False, + ) -> bool: + """Update version metadata in a YAML component file.""" + + with open(file_path, encoding="utf-8") as f: + content = f.read() + data = yaml.safe_load(content) or {} + old_version = utils.get_version_from_data(data) + + if new_version is None: + ref_version = None + if reference_content_getter: + ref_content = reference_content_getter(file_path) + if ref_content: + ref_version = self._get_yaml_version(ref_content) + if ref_version: + new_version = self.increment_version(ref_version) + self.log.info(f" 📊 Reference version: {ref_version} → bumping to {new_version}") + if new_version is None: + if old_version: + new_version = self.increment_version(old_version) + self.log.info(f" 📊 Local version: {old_version} → bumping to {new_version}") + else: + new_version = "0.1" + self.log.info(" 📝 No existing version - using 0.1") + else: + parts = self.parse_version(new_version) + new_version = ".".join(str(part) for part in parts) + + self.log.info(f" {Path(file_path).name}:") + self.log.info(f" Current version: {old_version or 'none'}") + self.log.info(f" New version: {new_version}") + + if not isinstance(data, dict): + self.log.warn(" ⚠️ Could not update YAML - root value is not a mapping") + return False + + if "metadata" not in data or data["metadata"] is None: + data["metadata"] = {} + if not isinstance(data["metadata"], dict): + self.log.warn(" ⚠️ Could not update YAML - metadata is not a mapping") + return False + if "annotations" not in data["metadata"] or data["metadata"]["annotations"] is None: + data["metadata"]["annotations"] = {} + if not isinstance(data["metadata"]["annotations"], dict): + self.log.warn(" ⚠️ Could not update YAML - metadata.annotations is not a mapping") + return False + + annotations = data["metadata"]["annotations"] + annotations["version"] = new_version + data.pop("version", None) + if update_timestamp: + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + self.log.info(f" Timestamp: {timestamp}") + annotations["updated_at"] = timestamp + else: + existing_timestamp = data.get("updated_at") or annotations.get("updated_at") + if existing_timestamp: + annotations["updated_at"] = existing_timestamp + data.pop("updated_at", None) + with open(file_path, "w", encoding="utf-8") as f: + f.write(utils.dump_yaml(data)) + + self.log.info(" ✅ Updated") + return True + + def update_python_file( + self, + python_file: str, + new_version: str | None = None, + reference_content_getter: ReferenceContentGetter | None = None, + update_timestamp: bool = False, + function_name: str | None = None, + ) -> bool: + """Update a Python component function docstring Metadata section.""" + + python_path = Path(python_file) + if function_name and not _has_exact_public_function(python_path, function_name): + self.log.warn(f" ⚠️ Function '{function_name}' not found in {python_path.name}") + return False + metadata, actual_func_name = extract_file_metadata(python_path, function_name) + if not actual_func_name: + self.log.warn(f" ⚠️ No function found in {python_path.name}") + return False + + current_version = metadata.get("version") + if new_version: + final_version = new_version + else: + ref_version = None + if reference_content_getter: + ref_content = reference_content_getter(python_file) + if ref_content: + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp: + tmp.write(ref_content) + tmp_path = Path(tmp.name) + try: + ref_metadata, _ = extract_file_metadata(tmp_path, actual_func_name) + ref_version = ref_metadata.get("version") + finally: + tmp_path.unlink() + if ref_version: + final_version = self.increment_version(ref_version) + self.log.info(f" 📊 Reference version: {ref_version} → bumping to {final_version}") + elif current_version: + final_version = self.increment_version(current_version) + self.log.info(f" 📊 Local version: {current_version} → bumping to {final_version}") + else: + final_version = "0.1" + self.log.info(" 📝 No existing version - using 0.1") + + current_timestamp = ( + datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + if update_timestamp + else None + ) + self.log.info(f" {python_path.name}:") + self.log.info(f" Current version: {current_version or 'none'}") + self.log.info(f" New version: {final_version}") + if current_timestamp: + self.log.info(f" Timestamp: {current_timestamp}") + + with open(python_file, encoding="utf-8") as f: + content = f.read() + new_content = self._update_function_docstring_metadata( + python_path, + content, + actual_func_name, + final_version, + current_timestamp, + ) + if new_content == content: + self.log.warn(" ⚠️ Could not update docstring - no Metadata section found") + return False + with open(python_file, "w", encoding="utf-8") as f: + f.write(new_content) + self.log.info(" ✅ Updated") + return True + + def _update_function_docstring_metadata( + self, + python_path: Path, + content: str, + function_name: str, + version: str, + timestamp: str | None = None, + ) -> str: + _, func_node = find_function_in_source(python_path, function_name) + if not func_node or not func_node.body: + return content + doc_node = func_node.body[0] + value = getattr(doc_node, "value", None) + if not ( + getattr(doc_node, "lineno", None) + and getattr(doc_node, "end_lineno", None) + and isinstance(getattr(value, "value", None), str) + ): + return content + + lines = content.splitlines(keepends=True) + start = doc_node.lineno - 1 + end = doc_node.end_lineno + docstring_source = "".join(lines[start:end]) + updated_docstring = self._update_docstring_metadata(docstring_source, version, timestamp) + if updated_docstring == docstring_source: + return content + return "".join([*lines[:start], updated_docstring, *lines[end:]]) + + def _update_docstring_metadata( + self, + content: str, + version: str, + timestamp: str | None = None, + ) -> str: + metadata_pattern = re.compile( + r"(Metadata:\s*\n)" + r"(\s+)" + r"(?:.*?\n)*?" + r"(?=\s*(?:Args:|Returns:|Raises:|Yields:|Note:|Example:|\"\"\"|\'\'\')|\Z)", + re.IGNORECASE | re.MULTILINE, + ) + + def replace_metadata(match: re.Match) -> str: + header = match.group(1) + indent = match.group(2) + result = f"{header}{indent}version: {version}\n" + if timestamp: + result += f"{indent}updated_at: {timestamp}\n" + return result + + return metadata_pattern.sub(replace_metadata, content, count=1) + + +def _has_exact_public_function(python_path: Path, function_name: str) -> bool: + """Return whether *python_path* defines exactly this public function.""" + + try: + tree = ast.parse(python_path.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return False + return any( + isinstance(node, ast.FunctionDef) and node.name == function_name and not node.name.startswith("_") + for node in ast.iter_child_nodes(tree) + ) + + +def _common_generation_dir(yaml_path: Path, annotations: dict[str, str]) -> Path | None: + component_yaml_path = annotations.get("component_yaml_path") + if not component_yaml_path: + return None + yaml_rel = Path(component_yaml_path) + if yaml_rel.is_absolute(): + return None + common_dir = yaml_path.resolve().parent + for part in yaml_rel.parent.parts: + if part not in ("", "."): + common_dir = common_dir.parent + return common_dir + + +def _resolve_annotated_path(yaml_path: Path, annotations: dict[str, str], annotation_key: str) -> Path | None: + raw_path = annotations.get(annotation_key) + if not raw_path: + return None + annotated_path = Path(raw_path) + if annotated_path.is_absolute(): + return annotated_path if annotated_path.exists() else None + + candidates: list[Path] = [] + common_dir = _common_generation_dir(yaml_path, annotations) + if common_dir: + candidates.append(common_dir / annotated_path) + candidates.append(yaml_path.parent / annotated_path) + + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve() + if resolved in seen: + continue + seen.add(resolved) + if resolved.exists(): + return resolved + return None + + +def _resolve_python_source_path(yaml_path: Path, annotations: dict[str, str]) -> Path | None: + """Resolve a component YAML's annotated Python source path. + + New generated YAML records both ``python_original_code_path`` and + ``component_yaml_path`` relative to a common ancestor. Older YAML may store + only the source basename, sometimes beside the YAML or under a sibling + ``sources`` directory. Try the structured common-ancestor form first, then + legacy locations. + """ + + raw_python_path = annotations.get("python_original_code_path") + if not raw_python_path: + return None + + python_path = Path(raw_python_path) + if python_path.is_absolute(): + return python_path if python_path.exists() else None + + candidates: list[Path] = [] + common_dir = _common_generation_dir(yaml_path, annotations) + if common_dir: + candidates.append(common_dir / python_path) + + yaml_dir = yaml_path.parent + candidates.extend( + [ + yaml_dir / python_path, + yaml_dir / "sources" / python_path.name, + yaml_dir / python_path.name, + ] + ) + + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve() + if resolved in seen: + continue + seen.add(resolved) + if resolved.exists(): + return resolved + return None + + +def bump_version( + yaml_file: str | Path, + set_version: str | None = None, + reference_content_getter: ReferenceContentGetter | None = None, + update_timestamp: bool = False, + logger: Logger | None = None, +) -> dict[str, str | None]: + """Bump component version in a YAML file. + + If the YAML references a local Python source via + ``metadata.annotations.python_original_code_path``, updates that source and + regenerates the YAML. Otherwise updates YAML metadata directly. + """ + + log = logger or get_default_logger() + yaml_path = Path(yaml_file) + if not yaml_path.exists(): + log.error(f"❌ File not found: {yaml_file}") + return {"status": "failed", "error": f"File not found: {yaml_file}"} + if yaml_path.suffix not in [".yaml", ".yml"]: + log.error(f"❌ Not a YAML file: {yaml_file}") + return {"status": "failed", "error": f"Not a YAML file: {yaml_file}"} + + version_manager = VersionManager(logger=log) + with open(yaml_path, encoding="utf-8") as f: + yaml_content = yaml.safe_load(f) or {} + old_version = utils.get_version_from_data(yaml_content) + + annotations: dict[str, str] = {} + metadata = yaml_content.get("metadata") if isinstance(yaml_content, dict) else None + if isinstance(metadata, dict) and isinstance(metadata.get("annotations"), dict): + annotations = metadata["annotations"] + python_path = annotations.get("python_original_code_path") + has_original_code = "python_original_code" in annotations + generation_function_name = annotations.get("tangle_cli_generation_function_name") + generation_mode = annotations.get("tangle_cli_generation_mode") or ( + "bundle" if annotations.get("bundled_modules") else "inline" + ) + if generation_mode not in {"inline", "bundle"}: + error = f"Unsupported generation mode: {generation_mode}" + log.error(f"❌ {error}") + return {"status": "failed", "yaml_file": str(yaml_path), "error": error} + custom_name = ( + yaml_content.get("name") + if isinstance(yaml_content, dict) and isinstance(yaml_content.get("name"), str) + else None + ) + + dependencies_from = None + if annotations.get("tangle_cli_generation_dependencies_from"): + dependencies_from = _resolve_annotated_path( + yaml_path, + annotations, + "tangle_cli_generation_dependencies_from", + ) + if dependencies_from is None: + error = f"Dependency file not found: {annotations['tangle_cli_generation_dependencies_from']}" + log.error(f"❌ {error}") + return {"status": "failed", "yaml_file": str(yaml_path), "error": error} + + resolve_root = None + if annotations.get("tangle_cli_generation_resolve_root"): + resolve_root = _resolve_annotated_path( + yaml_path, + annotations, + "tangle_cli_generation_resolve_root", + ) + if resolve_root is None: + error = f"Resolve root not found: {annotations['tangle_cli_generation_resolve_root']}" + log.error(f"❌ {error}") + return {"status": "failed", "yaml_file": str(yaml_path), "error": error} + + if python_path: + python_full_path = _resolve_python_source_path(yaml_path, annotations) + if python_full_path: + log.info(f" 📍 Found Python source: {python_full_path.name}") + success = version_manager.update_python_file( + str(python_full_path), + new_version=set_version, + reference_content_getter=reference_content_getter, + update_timestamp=update_timestamp, + function_name=generation_function_name, + ) + if success: + log.info(" 🔄 Regenerating YAML...") + success = ComponentGenerator(logger=log).regenerate_yaml( + python_full_path, + output_path=yaml_path, + function_name=generation_function_name, + custom_name=custom_name, + dependencies_from=dependencies_from, + strip_code=not has_original_code, + mode=generation_mode, + resolve_root=resolve_root, + ) + else: + log.error(f"❌ Python source not found: {python_path}") + return { + "status": "failed", + "yaml_file": str(yaml_path), + "error": f"Python source not found: {python_path}", + } + else: + success = version_manager.update_yaml_file( + str(yaml_path), + new_version=set_version, + reference_content_getter=reference_content_getter, + update_timestamp=update_timestamp, + ) + + if not success: + return {"status": "failed", "yaml_file": str(yaml_path), "error": "Version update failed"} + + with open(yaml_path, encoding="utf-8") as f: + new_version = utils.get_version_from_data(yaml.safe_load(f)) + return { + "status": "success", + "yaml_file": str(yaml_path), + "old_version": old_version, + "new_version": new_version, + } + + +__all__ = ["ReferenceContentGetter", "VersionManager", "bump_version"] diff --git a/pyproject.toml b/pyproject.toml index 9723faf..42daa28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.0.1" +version = "0.1.0" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ @@ -10,8 +10,15 @@ authors = [ requires-python = ">=3.10" dependencies = [ "cloud-pipelines>=0.26.3.12", - "cloud-pipelines-backend", - "cyclopts>=3.0", + "cyclopts>=4.16.1", + "docstring-parser>=0.16", + "httpx>=0.28.1", + "jinja2>=3.1", + "platformdirs>=4.10.0", + "pydantic>=2.0", + "pyyaml>=6.0", + "requests>=2.32.0", + "tomli>=2.0; python_version < '3.11'", ] [project.urls] @@ -20,21 +27,48 @@ Documentation = "https://tangleml.com/docs/" Repository = "https://github.com/TangleML/tangle-cli" Issues = "https://github.com/TangleML/tangle-cli/issues" +[project.optional-dependencies] +native = ["tangle-api==0.1.0"] + [project.scripts] -tangle = "tangle_cli.cli:app" +tangle = "tangle_cli.cli:main" [build-system] requires = ["uv_build>=0.11.2,<0.12.0"] build-backend = "uv_build" [tool.uv.build-backend] -# Set the module-root to an empty string for a flat layout -module-root = "" +module-root = "packages/tangle-cli/src" [tool.uv.sources] cloud-pipelines-backend = { git = "https://github.com/TangleML/tangle", rev = "stable_cli" } +tangle-api = { workspace = true } +tangle-cli = { workspace = true } + +[tool.uv.workspace] +members = ["packages/tangle-api"] + +[tool.pytest.ini_options] +testpaths = ["tests"] [dependency-groups] dev = [ "pytest>=9.0.2", + "tangle-api", +] +codegen = [ + # Mirrors third_party/tangle backend import requirements used by + # `python -m tangle_cli.openapi.codegen --backend-path third_party/tangle`. + "cloud-pipelines-backend", + "alembic>=1.18.4", + "bugsnag>=4.9.0,<5", + "cloud-pipelines>=0.23.2.4", + "fastapi[standard]>=0.115.12", + "kubernetes>=33.1.0,<36", + "opentelemetry-api>=1.41.1", + "opentelemetry-exporter-otlp-proto-grpc>=1.41.1", + "opentelemetry-exporter-otlp-proto-http>=1.39.1", + "opentelemetry-instrumentation-fastapi>=0.60b1", + "opentelemetry-sdk>=1.39.1", + "sqlalchemy>=2.0.49", ] diff --git a/tangle_cli/api/__init__.py b/tangle_cli/api/__init__.py deleted file mode 100644 index 533f039..0000000 --- a/tangle_cli/api/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Client access for the Tangle API CLI. - -The generated ``tangle api ...`` commands send their HTTP requests through a -single, globally-initialized :class:`~tangle_cli.api.client.Client` instance. -Use :func:`get_client` to obtain it and :func:`set_client` to override it -(for example, to point at a different server or to inject authentication). - -The default client reads its configuration from the environment: - -* ``TANGLE_API_URL`` -- base URL of the API server - (default: ``http://127.0.0.1:8000``). -* ``TANGLE_API_TOKEN`` -- if set, requests are sent with bearer authentication. -""" - -import os - -from . import client - -__all__ = [ - "AuthenticatedClient", - "Client", - "DEFAULT_BASE_URL", - "get_client", - "set_client", -] - -AuthenticatedClient = client.AuthenticatedClient -Client = client.Client - -DEFAULT_BASE_URL = "http://127.0.0.1:8000" - -_client: client.Client | None = None - - -def _build_default_client() -> client.Client: - base_url = os.environ.get("TANGLE_API_URL", DEFAULT_BASE_URL) - token = os.environ.get("TANGLE_API_TOKEN") - if token: - return client.AuthenticatedClient(base_url=base_url, token=token) - return client.Client(base_url=base_url) - - -def get_client() -> client.Client: - """Return the global API client, creating a default one if needed.""" - global _client - if _client is None: - _client = _build_default_client() - return _client - - -def set_client(client: client.Client) -> None: - """Replace the global API client used by the generated CLI commands.""" - global _client - _client = client diff --git a/tangle_cli/api/cli_generator.py b/tangle_cli/api/cli_generator.py deleted file mode 100644 index 882f3ba..0000000 --- a/tangle_cli/api/cli_generator.py +++ /dev/null @@ -1,451 +0,0 @@ -"""Generate ``tangle api`` CLI commands from the real FastAPI backend. - -The backend's routes are introspected (path, HTTP method, handler signature) -and turned into a tree of :mod:`cyclopts` commands that call the API server: - -* The first non-parameter URL path segment becomes the command **group** - (e.g. ``/api/pipeline_runs/{id}`` -> ``pipeline-runs``). -* The route's handler name becomes the **command** - (e.g. ``get_signed_artifact_url`` -> ``get-signed-artifact-url``). -* Handler parameters are classified by FastAPI itself - (``route.dependant``) into URL-path, query-string and request-body - parameters. Parameters injected via ``fastapi.Depends`` (database session, - current user, permission checks, ...) are *not* part of the public API and - are skipped automatically. -* Primitive parameters stay primitive on the CLI. Parameters with a complex - type (Pydantic model, dataclass, list, dict, ...) accept a JSON value or a - ``@path`` to a JSON/YAML file, and are validated against the declared type - on a best-effort basis. - -Every generated command also accepts ``--debug``, which prints the HTTP -request that would be sent (method, URL and body) instead of sending it. -""" - -import dataclasses -import datetime -import enum -import inspect -import json -import pathlib -import sys -import types -import typing -import urllib.parse -import uuid -from typing import Annotated, Any, Optional - -import cyclopts -import fastapi -import fastapi.routing - -from . import get_client - -_SCALAR_TYPES: tuple[type, ...] = ( - str, - int, - float, - bool, - bytes, - datetime.datetime, - datetime.date, - datetime.time, - uuid.UUID, -) - -_COMPLEX_ARG_HELP = "JSON value, or @path to a JSON/YAML file." - - -# --------------------------------------------------------------------------- # -# Type helpers -# --------------------------------------------------------------------------- # -def _strip_optional(annotation: Any) -> Any: - """Return ``annotation`` with a single trailing ``None`` member removed.""" - origin = typing.get_origin(annotation) - if origin is typing.Union or origin is types.UnionType: - args = [a for a in typing.get_args(annotation) if a is not type(None)] - if len(args) == 1: - return args[0] - return typing.Union[tuple(args)] # noqa: UP007 - dynamic union - return annotation - - -def _is_scalar(annotation: Any) -> bool: - core = _strip_optional(annotation) - return isinstance(core, type) and issubclass(core, _SCALAR_TYPES) - - -def _jsonable(value: Any) -> Any: - """Convert a scalar into something ``json.dumps`` can handle.""" - if isinstance(value, enum.Enum): - return value.value - if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): - return value.isoformat() - return value - - -def _query_value(value: Any) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, enum.Enum): - return str(value.value) - if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): - return value.isoformat() - return str(value) - - -def _kebab(name: str) -> str: - return name.replace("_", "-").strip("-") - - -def _resource_group(path: str) -> str: - """The first static (non-templated) path segment after an ``api`` prefix.""" - segments = [s for s in path.strip("/").split("/") if s] - if segments and segments[0] == "api": - segments = segments[1:] - for segment in segments: - if not (segment.startswith("{") and segment.endswith("}")): - return _kebab(segment) - return "default" - - -# --------------------------------------------------------------------------- # -# Route plan -# --------------------------------------------------------------------------- # -@dataclasses.dataclass -class _BodyParam: - name: str - is_scalar: bool - required: bool - default: Any - core_type: Any - - -@dataclasses.dataclass -class _QueryParam: - name: str - default: Any - - -@dataclasses.dataclass -class _RoutePlan: - method: str - path: str - path_params: list[str] - query_params: list[_QueryParam] - body_params: list[_BodyParam] - body_is_direct: bool - - -def _primary_method(route: fastapi.routing.APIRoute) -> str | None: - methods = sorted((route.methods or set()) - {"HEAD", "OPTIONS"}) - return methods[0] if methods else None - - -def _build_plan_and_signature( - route: fastapi.routing.APIRoute, method: str -) -> tuple[_RoutePlan, inspect.Signature]: - dependant = route.dependant - parameters: list[inspect.Parameter] = [] - empty = inspect.Parameter.empty - - # URL path parameters -> required positional arguments. - path_param_names: list[str] = [] - for field in dependant.path_params: - path_param_names.append(field.name) - parameters.append( - inspect.Parameter( - field.name, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - annotation=field.field_info.annotation, - ) - ) - - # Query-string parameters -> keyword options (native types). - query_params: list[_QueryParam] = [] - for field in dependant.query_params: - required = field.field_info.is_required() - default = empty if required else field.field_info.default - query_params.append(_QueryParam(name=field.name, default=field.field_info.default)) - parameters.append( - inspect.Parameter( - field.name, - inspect.Parameter.KEYWORD_ONLY, - default=default, - annotation=field.field_info.annotation, - ) - ) - - # Request-body parameters -> keyword options (JSON/@file for complex types). - body_params: list[_BodyParam] = [] - for field in dependant.body_params: - annotation = field.field_info.annotation - required = field.field_info.is_required() - is_scalar = _is_scalar(annotation) - body_params.append( - _BodyParam( - name=field.name, - is_scalar=is_scalar, - required=required, - default=field.field_info.default, - core_type=_strip_optional(annotation), - ) - ) - if is_scalar: - cli_annotation = annotation - default = empty if required else field.field_info.default - else: - inner = str if required else Optional[str] - cli_annotation = Annotated[inner, cyclopts.Parameter(help=_COMPLEX_ARG_HELP)] - default = empty if required else None - parameters.append( - inspect.Parameter( - field.name, - inspect.Parameter.KEYWORD_ONLY, - default=default, - annotation=cli_annotation, - ) - ) - - # A single body parameter is sent as the whole body only when FastAPI did - # not wrap it in a generated ``Body_...`` model (i.e. no ``embed=True`` and - # not part of a multi-parameter body). - body_field = route.body_field - body_is_direct = bool( - body_field is not None - and len(body_params) == 1 - and body_field.name == body_params[0].name - ) - - if "debug" not in {p.name for p in parameters}: - parameters.append( - inspect.Parameter( - "debug", - inspect.Parameter.KEYWORD_ONLY, - default=False, - annotation=Annotated[ - bool, - cyclopts.Parameter( - help="Print the HTTP request (method, URL, body) instead of sending it." - ), - ], - ) - ) - - plan = _RoutePlan( - method=method, - path=route.path, - path_params=path_param_names, - query_params=query_params, - body_params=body_params, - body_is_direct=body_is_direct, - ) - return plan, inspect.Signature(parameters) - - -# --------------------------------------------------------------------------- # -# Argument -> request translation -# --------------------------------------------------------------------------- # -def _load_structured(text: str) -> Any: - try: - return json.loads(text) - except json.JSONDecodeError: - import yaml # Lazy import; only needed for complex arguments. - - return yaml.safe_load(text) - - -def _parse_complex_arg(raw: str, spec: _BodyParam) -> Any: - text = raw - if raw.startswith("@"): - text = pathlib.Path(raw[1:]).read_text(encoding="utf-8") - value = _load_structured(text) - - # Best-effort validation against the declared model. - model = spec.core_type - validator = getattr(model, "from_json_dict", None) or getattr(model, "model_validate", None) - if validator is not None: - try: - validator(value) - except Exception as exc: # noqa: BLE001 - validation is advisory only. - cli_name = "--" + _kebab(spec.name) - print(f"Warning: {cli_name} failed validation against {getattr(model, '__name__', model)}: {exc}", - file=sys.stderr) - return value - - -def _build_path(plan: _RoutePlan, values: dict[str, Any]) -> str: - path = plan.path - for name in plan.path_params: - encoded = urllib.parse.quote(str(values[name]), safe="") - path = path.replace("{" + name + "}", encoded) - return path - - -def _build_query(plan: _RoutePlan, values: dict[str, Any]) -> list[tuple[str, str]] | None: - params: list[tuple[str, str]] = [] - for spec in plan.query_params: - value = values.get(spec.name) - # Omit unset values and values left at their default: the server - # applies the same default, and it keeps the request URL clean. - if value is None or value == spec.default: - continue - if isinstance(value, (list, tuple, set)): - params.extend((spec.name, _query_value(item)) for item in value) - else: - params.append((spec.name, _query_value(value))) - return params or None - - -def _build_body(plan: _RoutePlan, values: dict[str, Any]) -> Any: - if not plan.body_params: - return None - assembled: dict[str, Any] = {} - for spec in plan.body_params: - value = values.get(spec.name) - if value is None and not spec.required: - continue - if spec.is_scalar: - assembled[spec.name] = _jsonable(value) - else: - assembled[spec.name] = _parse_complex_arg(value, spec) - if plan.body_is_direct: - return next(iter(assembled.values())) if assembled else None - return assembled - - -# --------------------------------------------------------------------------- # -# Output -# --------------------------------------------------------------------------- # -def _emit_request(request) -> None: - print(f"{request.method} {request.url}") - if request.content: - print(request.content.decode("utf-8", errors="replace")) - - -def _emit_response(response) -> None: - content_type = response.headers.get("content-type", "") - text = response.text - if "application/json" in content_type and text: - try: - text = json.dumps(response.json(), indent=2, ensure_ascii=False) - except ValueError: - pass - if response.is_success: - if text: - print(text) - return - print(f"HTTP {response.status_code} {response.reason_phrase}", file=sys.stderr) - if text: - print(text, file=sys.stderr) - raise SystemExit(1) - - -# --------------------------------------------------------------------------- # -# Command factory -# --------------------------------------------------------------------------- # -def _make_command(plan: _RoutePlan, signature: inspect.Signature, doc: str | None): - def command(*args: Any, **kwargs: Any) -> None: - bound = signature.bind(*args, **kwargs) - bound.apply_defaults() - values = dict(bound.arguments) - debug = values.pop("debug", False) - - path = _build_path(plan, values) - query = _build_query(plan, values) - body = _build_body(plan, values) - - httpx_client = get_client().get_httpx_client() - request = httpx_client.build_request( - plan.method, - path, - params=query, - json=body if body is not None else None, - ) - - if debug: - _emit_request(request) - return - - import httpx # Lazy import; only needed when actually sending. - - try: - response = httpx_client.send(request) - except httpx.HTTPError as exc: - print(f"Request to {request.url} failed: {exc}", file=sys.stderr) - raise SystemExit(1) from exc - _emit_response(response) - - command.__signature__ = signature # type: ignore[attr-defined] - command.__name__ = _build_python_name(plan) - command.__doc__ = doc - return command - - -def _build_python_name(plan: _RoutePlan) -> str: - return f"{plan.method.lower()}_{plan.path.strip('/').replace('/', '_').replace('{', '').replace('}', '')}" - - -# --------------------------------------------------------------------------- # -# Public entry points -# --------------------------------------------------------------------------- # -def build_backend_app() -> fastapi.FastAPI: - """Build the FastAPI app from the backend for route introspection.""" - from cloud_pipelines_backend import api_router - - app = fastapi.FastAPI() - api_router.setup_routes( - app=app, - db_engine=None, - user_details_getter=lambda *args, **kwargs: None, - ) - return app - - -def build_api_cli_app( - name: str = "api", fastapi_app: fastapi.FastAPI | None = None -) -> cyclopts.App: - """Build a cyclopts app whose commands call the backend's HTTP API.""" - if fastapi_app is None: - fastapi_app = build_backend_app() - - app = cyclopts.App(name=name, help="Call the Tangle API server.") - groups: dict[str, cyclopts.App] = {} - registered: dict[tuple[str, str], Any] = {} - - # Counter so that CLI command order matches the API route order. - counter = 0 - routes = fastapi_app.routes - for route in routes: - if not isinstance(route, fastapi.routing.APIRoute): - continue - method = _primary_method(route) - if method is None: - continue - - group_name = _resource_group(route.path) - command_name = _kebab(route.name) - key = (group_name, command_name) - - existing = registered.get(key) - if existing is not None: - # The same handler is sometimes registered under several URLs - # (e.g. a deprecated alias). Keep the first; only disambiguate - # genuinely different handlers. - if existing is route.endpoint: - continue - command_name = f"{command_name}-{method.lower()}" - key = (group_name, command_name) - - group_app = groups.get(group_name) - if group_app is None: - group_app = cyclopts.App(name=group_name) - groups[group_name] = group_app - app.command(group_app) - - plan, signature = _build_plan_and_signature(route, method) - command = _make_command(plan, signature, doc=route.endpoint.__doc__) - group_app.command(command, name=command_name, sort_key=counter) - counter += 1 - registered[key] = route.endpoint - - return app diff --git a/tangle_cli/api/client.py b/tangle_cli/api/client.py deleted file mode 100644 index 1b7055a..0000000 --- a/tangle_cli/api/client.py +++ /dev/null @@ -1,268 +0,0 @@ -import ssl -from typing import Any - -import httpx -from attrs import define, evolve, field - - -@define -class Client: - """A class for keeping track of data related to the API - - The following are accepted as keyword arguments and will be used to construct httpx Clients internally: - - ``base_url``: The base URL for the API, all requests are made to a relative path to this URL - - ``cookies``: A dictionary of cookies to be sent with every request - - ``headers``: A dictionary of headers to be sent with every request - - ``timeout``: The maximum amount of a time a request can take. API functions will raise - httpx.TimeoutException if this is exceeded. - - ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, - but can be set to False for testing purposes. - - ``follow_redirects``: Whether or not to follow redirects. Default value is False. - - ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. - - - Attributes: - raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a - status code that was not documented in the source OpenAPI document. Can also be provided as a keyword - argument to the constructor. - """ - - raise_on_unexpected_status: bool = field(default=False, kw_only=True) - _base_url: str = field(alias="base_url") - _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") - _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") - _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") - _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") - _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") - _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") - _client: httpx.Client | None = field(default=None, init=False) - _async_client: httpx.AsyncClient | None = field(default=None, init=False) - - def with_headers(self, headers: dict[str, str]) -> "Client": - """Get a new client matching this one with additional headers""" - if self._client is not None: - self._client.headers.update(headers) - if self._async_client is not None: - self._async_client.headers.update(headers) - return evolve(self, headers={**self._headers, **headers}) - - def with_cookies(self, cookies: dict[str, str]) -> "Client": - """Get a new client matching this one with additional cookies""" - if self._client is not None: - self._client.cookies.update(cookies) - if self._async_client is not None: - self._async_client.cookies.update(cookies) - return evolve(self, cookies={**self._cookies, **cookies}) - - def with_timeout(self, timeout: httpx.Timeout) -> "Client": - """Get a new client matching this one with a new timeout configuration""" - if self._client is not None: - self._client.timeout = timeout - if self._async_client is not None: - self._async_client.timeout = timeout - return evolve(self, timeout=timeout) - - def set_httpx_client(self, client: httpx.Client) -> "Client": - """Manually set the underlying httpx.Client - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._client = client - return self - - def get_httpx_client(self) -> httpx.Client: - """Get the underlying httpx.Client, constructing a new one if not previously set""" - if self._client is None: - self._client = httpx.Client( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._client - - def __enter__(self) -> "Client": - """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" - self.get_httpx_client().__enter__() - return self - - def __exit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for internal httpx.Client (see httpx docs)""" - self.get_httpx_client().__exit__(*args, **kwargs) - - def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": - """Manually set the underlying httpx.AsyncClient - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._async_client = async_client - return self - - def get_async_httpx_client(self) -> httpx.AsyncClient: - """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" - if self._async_client is None: - self._async_client = httpx.AsyncClient( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._async_client - - async def __aenter__(self) -> "Client": - """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" - await self.get_async_httpx_client().__aenter__() - return self - - async def __aexit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" - await self.get_async_httpx_client().__aexit__(*args, **kwargs) - - -@define -class AuthenticatedClient: - """A Client which has been authenticated for use on secured endpoints - - The following are accepted as keyword arguments and will be used to construct httpx Clients internally: - - ``base_url``: The base URL for the API, all requests are made to a relative path to this URL - - ``cookies``: A dictionary of cookies to be sent with every request - - ``headers``: A dictionary of headers to be sent with every request - - ``timeout``: The maximum amount of a time a request can take. API functions will raise - httpx.TimeoutException if this is exceeded. - - ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, - but can be set to False for testing purposes. - - ``follow_redirects``: Whether or not to follow redirects. Default value is False. - - ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. - - - Attributes: - raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a - status code that was not documented in the source OpenAPI document. Can also be provided as a keyword - argument to the constructor. - token: The token to use for authentication - prefix: The prefix to use for the Authorization header - auth_header_name: The name of the Authorization header - """ - - raise_on_unexpected_status: bool = field(default=False, kw_only=True) - _base_url: str = field(alias="base_url") - _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") - _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") - _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") - _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") - _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") - _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") - _client: httpx.Client | None = field(default=None, init=False) - _async_client: httpx.AsyncClient | None = field(default=None, init=False) - - token: str - prefix: str = "Bearer" - auth_header_name: str = "Authorization" - - def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": - """Get a new client matching this one with additional headers""" - if self._client is not None: - self._client.headers.update(headers) - if self._async_client is not None: - self._async_client.headers.update(headers) - return evolve(self, headers={**self._headers, **headers}) - - def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": - """Get a new client matching this one with additional cookies""" - if self._client is not None: - self._client.cookies.update(cookies) - if self._async_client is not None: - self._async_client.cookies.update(cookies) - return evolve(self, cookies={**self._cookies, **cookies}) - - def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": - """Get a new client matching this one with a new timeout configuration""" - if self._client is not None: - self._client.timeout = timeout - if self._async_client is not None: - self._async_client.timeout = timeout - return evolve(self, timeout=timeout) - - def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": - """Manually set the underlying httpx.Client - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._client = client - return self - - def get_httpx_client(self) -> httpx.Client: - """Get the underlying httpx.Client, constructing a new one if not previously set""" - if self._client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token - self._client = httpx.Client( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._client - - def __enter__(self) -> "AuthenticatedClient": - """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" - self.get_httpx_client().__enter__() - return self - - def __exit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for internal httpx.Client (see httpx docs)""" - self.get_httpx_client().__exit__(*args, **kwargs) - - def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": - """Manually set the underlying httpx.AsyncClient - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._async_client = async_client - return self - - def get_async_httpx_client(self) -> httpx.AsyncClient: - """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" - if self._async_client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token - self._async_client = httpx.AsyncClient( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._async_client - - async def __aenter__(self) -> "AuthenticatedClient": - """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" - await self.get_async_httpx_client().__aenter__() - return self - - async def __aexit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" - await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/tangle_cli/api_cli.py b/tangle_cli/api_cli.py deleted file mode 100644 index beb763b..0000000 --- a/tangle_cli/api_cli.py +++ /dev/null @@ -1,10 +0,0 @@ -"""The ``tangle api`` command group. - -The commands are generated dynamically from the backend's FastAPI routes, so -they always match the running server's API. See -:mod:`tangle_cli.api.cli_generator` for details. -""" - -from .api import cli_generator - -app = cli_generator.build_api_cli_app(name="api") diff --git a/tangle_cli/cli.py b/tangle_cli/cli.py deleted file mode 100644 index dccb95b..0000000 --- a/tangle_cli/cli.py +++ /dev/null @@ -1,13 +0,0 @@ -import cyclopts - -from . import api_cli -from . import components_cli - -app = cyclopts.App() - -app.command(api_cli.app) -app.command(components_cli.app) - - -if __name__ == "__main__": - app() diff --git a/tangle_cli/components_cli.py b/tangle_cli/components_cli.py deleted file mode 100644 index 60256cb..0000000 --- a/tangle_cli/components_cli.py +++ /dev/null @@ -1,73 +0,0 @@ -import pathlib - -import cyclopts - -app = cyclopts.App(name="components") - -generate_app = cyclopts.App(name="generate") -app.command(generate_app) - -component_references_app = cyclopts.App(name="component-references") -app.command(component_references_app) - -annotations_app = cyclopts.App(name="annotations") -app.command(annotations_app) - -# region components - - -@app.command(name="validate") -def components_validate(component_path: str): - raise NotImplementedError() - - -@app.command(name="set-container-image") -def components_set_container_image(component_path: str): - raise NotImplementedError() - - -# endregion - - -# region components/annotations - - -@annotations_app.command(name="set") -def components_annotations_set( - component_path: str, key: str, value: str, output_component_path: str | None = None -): - """Sets annotation value in component file.""" - raise NotImplementedError() - - -@annotations_app.command(name="get") -def components_annotations_get(component_path: str, keys: list[str]): - """Sets annotation values from component file.""" - print(locals()) - raise NotImplementedError() - - -# endregion - - -# region components/generate - -_from_template_app = cyclopts.App(name="from-template", show=False) -generate_app.command(_from_template_app) - - -@_from_template_app.default -def components_generate_from_template( - template_name: str, - output_component_path: pathlib.Path, -): - raise NotImplementedError() - - -@generate_app.command(name="from-python-function") -def components_generate_from_python_function(output_component_path: str): - """Generates component from a Python function""" - raise NotImplementedError() - - -# endregion diff --git a/tests/fixtures/python_pipeline/task_env_strip_annotation_op.py b/tests/fixtures/python_pipeline/task_env_strip_annotation_op.py new file mode 100644 index 0000000..5902a16 --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_annotation_op.py @@ -0,0 +1,28 @@ +"""Fixture: a co-located env used ONLY in a return type annotation (FIX N1). + +The module-level env object exists ONLY to feed the decorator's env argument and +is ALSO referenced as a return type annotation, but NEVER in the function body. +Type annotations are removed from the baked program by a later type-hint pass, +so an annotation-only reference must not be mistaken for a live runtime +reference and must not trip the "still referenced by kept code" fail-fast. The +runtime strip must drop the authoring import, the env declaration, the +decorator, AND the annotation, so the baked program is env-free and runs without +a NameError. (Authoring tokens kept out of this docstring on purpose so the +strip test can substring-assert their absence in the baked program.) +""" +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import TaskEnv, task + +UPI = TaskEnv(image="python:3.12") + + +@task(env=UPI) +def task_env_strip_annotation(out: components.OutputPath("Text"), who: str = "world") -> UPI: + """ + Metadata: + Name: Task Env Strip Annotation + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_body_ref_op.py b/tests/fixtures/python_pipeline/task_env_strip_body_ref_op.py new file mode 100644 index 0000000..1a690ce --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_body_ref_op.py @@ -0,0 +1,26 @@ +"""Fixture: an env name referenced by the task body (invalid contract). + +`UPI` is an authoring-only env declaration whose definition the strip removes, +but the task body also references `UPI` at runtime. Baking that would leave a +NameError at container start, so the strip must FAIL FAST (AuthoringStripError) +with guidance that env values are authoring-only. +""" +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import TaskEnv, task + +UPI = TaskEnv(image="python:3.12") + + +@task(env=UPI) +def task_env_strip_body_ref(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Body Ref + Version: 1.0.0 + """ + # Invalid: referencing the env object at runtime. Its declaration is + # stripped, so this would be a NameError in the baked program. + image = UPI.image + with open(out, "w") as fh: + fh.write(f"hi {who} on {image}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_colocated_op.py b/tests/fixtures/python_pipeline/task_env_strip_colocated_op.py new file mode 100644 index 0000000..18e2cc8 --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_colocated_op.py @@ -0,0 +1,24 @@ +"""Fixture: a co-located reusable env declaration feeding a decorated task. + +The module-level env object exists ONLY to feed the decorator's env argument. +The runtime strip must drop the authoring import, the env declaration, AND the +decorator so the baked program does not crash referencing a stripped authoring +name at container start. (Tokens kept out of this docstring on purpose so the +strip test can substring-assert their absence in the baked program.) +""" +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import TaskEnv, task + +UPI = TaskEnv(image="python:3.12") + + +@task(env=UPI) +def task_env_strip_colocated(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Colocated + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_envs.py b/tests/fixtures/python_pipeline/task_env_strip_envs.py new file mode 100644 index 0000000..b14678b --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_envs.py @@ -0,0 +1,25 @@ +"""Fixture: an authoring-only ``TaskEnv`` module imported by strip fixtures. + +This sibling module is intentionally NOT packaged into the runtime image. A +baked operation that still ``import``s it (or references ``UPI``) would crash +with ``ImportError`` / ``NameError`` at container start -- exactly what the +TaskEnv runtime-strip hardening prevents. + +Used by: +- ``task_env_strip_imported_op.py`` (``from task_env_strip_envs import UPI``) +- ``task_env_strip_module_op.py`` (``import task_env_strip_envs``) +- ``task_env_strip_mixed_import_op.py``(``from task_env_strip_envs import UPI, helper``) + +``helper`` is a stand-in RUNTIME name used to exercise the mixed-import +fail-fast: an env-only name sharing an import statement with a runtime name. +""" + +from tangle_deploy.python_pipeline import TaskEnv + +UPI = TaskEnv(image="python:3.12") + + +def helper(value): + """A runtime helper that (in a real project) would be packaged into the + image — here only used to trip the mixed-import fail-fast.""" + return f"hi {value}" diff --git a/tests/fixtures/python_pipeline/task_env_strip_imported_op.py b/tests/fixtures/python_pipeline/task_env_strip_imported_op.py new file mode 100644 index 0000000..94a0bb2 --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_imported_op.py @@ -0,0 +1,23 @@ +"""Fixture: a decorated task whose env is imported from a sibling module. + +The sibling import is authoring-only. The runtime strip must drop that import +AND the decorator so the baked program does not crash with an import error (the +sibling module is not present in the runtime image). Authoring tokens are kept +out of this docstring on purpose so the strip test can substring-assert their +absence in the baked program. +""" +from cloud_pipelines import components +from task_env_strip_envs import UPI + +from tangle_deploy.python_pipeline import task + + +@task(env=UPI) +def task_env_strip_imported(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Imported + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_inline_op.py b/tests/fixtures/python_pipeline/task_env_strip_inline_op.py new file mode 100644 index 0000000..be3afdf --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_inline_op.py @@ -0,0 +1,22 @@ +"""Fixture: an inline reusable-env object constructed in the decorator argument. + +The env object is constructed as a decorator argument, so the whole decorator +line range is deleted by the strip -- no residual env-construction text should +survive into the baked program. Authoring tokens are kept out of this docstring +on purpose so the strip test can substring-assert their absence in the baked +program. +""" +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import TaskEnv, task + + +@task(env=TaskEnv(image="python:3.12")) +def task_env_strip_inline(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Inline + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_mixed_import_op.py b/tests/fixtures/python_pipeline/task_env_strip_mixed_import_op.py new file mode 100644 index 0000000..3f478f0 --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_mixed_import_op.py @@ -0,0 +1,23 @@ +"""Fixture: an env-only name sharing an import line with a used runtime name. + +`from task_env_strip_envs import UPI, helper` mixes an authoring-only env name +(UPI) with a runtime helper that the body actually calls. The strip cannot +line-delete only part of the statement, so it must FAIL FAST (AuthoringStripError) +with guidance to split the import -- never bake a likely-broken +`from task_env_strip_envs import UPI` line into the runtime program. +""" +from cloud_pipelines import components +from task_env_strip_envs import UPI, helper + +from tangle_deploy.python_pipeline import task + + +@task(env=UPI) +def task_env_strip_mixed_import(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Mixed Import + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(helper(who)) diff --git a/tests/fixtures/python_pipeline/task_env_strip_module_op.py b/tests/fixtures/python_pipeline/task_env_strip_module_op.py new file mode 100644 index 0000000..af63dd7 --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_module_op.py @@ -0,0 +1,23 @@ +"""Fixture: a decorated task whose env is read via a sibling module import. + +The strip collects the module-alias root from the decorator's env argument and +must drop the module import line AND the decorator, so the baked program does +not crash with an import error at container start. Authoring tokens are kept out +of this docstring on purpose so the strip test can substring-assert their +absence in the baked program. +""" +import task_env_strip_envs +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import task + + +@task(env=task_env_strip_envs.UPI) +def task_env_strip_module(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Module + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_nested_import_op.py b/tests/fixtures/python_pipeline/task_env_strip_nested_import_op.py new file mode 100644 index 0000000..296448b --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_nested_import_op.py @@ -0,0 +1,27 @@ +"""Fixture: an env import nested inside a block (FIX N2, §3.5). + +`from task_env_strip_envs import UPI` lives inside an `if` block, so it is NOT a +direct child of the module body. Module-level removal only touches `tree.body`, +so this nested env import would NOT be stripped and would LEAK into the baked +runtime program -> ImportError at container start (the sibling authoring module +is not in the runtime image). Line-deleting the nested import is unsafe too +(it would leave an empty block -> IndentationError). The strip must therefore +FAIL FAST (AuthoringStripError) with guidance to move it to a top-level import. +""" +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import task + +if True: + from task_env_strip_envs import UPI + + +@task(env=UPI) +def task_env_strip_nested_import(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Nested Import + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/fixtures/python_pipeline/task_env_strip_override_op.py b/tests/fixtures/python_pipeline/task_env_strip_override_op.py new file mode 100644 index 0000000..63638de --- /dev/null +++ b/tests/fixtures/python_pipeline/task_env_strip_override_op.py @@ -0,0 +1,25 @@ +"""Fixture: a decorated task with an explicit image override of its env. + +An explicit image overrides the env's image (a Phase 2 sidecar concern). The +strip still has to drop the co-located env declaration, the authoring import, +and the whole decorator from the baked program -- including the override string, +which lives only inside the decorator. Authoring tokens are kept out of this +docstring on purpose so the strip test can substring-assert their absence in the +baked program. +""" +from cloud_pipelines import components + +from tangle_deploy.python_pipeline import TaskEnv, task + +UPI = TaskEnv(image="python:3.12") + + +@task(env=UPI, image="python:3.13-slim") +def task_env_strip_override(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Task Env Strip Override + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") diff --git a/tests/snapshots/component_generator/bundle_mode.expected.yaml b/tests/snapshots/component_generator/bundle_mode.expected.yaml new file mode 100644 index 0000000..932fe71 --- /dev/null +++ b/tests/snapshots/component_generator/bundle_mode.expected.yaml @@ -0,0 +1,113 @@ +name: My component +description: Clean a name. +metadata: + annotations: + cloud_pipelines.net: 'true' + components new regenerate python-function-component: 'true' + tangle_cli_generation_function_name: my_component + tangle_cli_generation_mode: bundle + python_original_code_path: my_component.py + python_original_code: | + from helpers.utils import clean + + def my_component(name: str) -> str: + """Clean a name. + + Metadata: + version: 1.0 + """ + return clean(name) + version: '1.0' + component_yaml_path: my-component.yaml + bundled_modules: '["helpers", "helpers.utils"]' +inputs: +- name: name + type: String +outputs: +- name: Output + type: String +implementation: + container: + image: python:3.12 + command: + - sh + - -ec + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + def _serialize_str(str_value) -> str: + if isinstance(str_value, str): + return str_value + else: + return str(str_value) + + # --- Inject local dependency modules from embedded source --- + import sys + import types + import base64 + import json + import zlib + + _EMBEDDED_MODULES = json.loads(zlib.decompress(base64.b64decode('eNqrVspIzSlILSpWslJQUtJRgHH1Sksyc8CCMXkpqWkKyTmpiXkaJakVJZpWMXkKQFCUWlJalKcAEtIrLinKLNDQ1MvJL08t0tCMyVOqBQAjHx2s'))) + # Pass 1: register all modules in sys.modules (without executing source) + # so transitive imports between bundled modules can resolve in any order. + _module_objs = {} + for _mod_name in _EMBEDDED_MODULES: + _parts = _mod_name.split('.') + for _i in range(1, len(_parts)): + _parent = '.'.join(_parts[:_i]) + if _parent not in sys.modules: + _pkg = types.ModuleType(_parent) + _pkg.__path__ = [] + _pkg.__package__ = _parent + sys.modules[_parent] = _pkg + _mod = types.ModuleType(_mod_name) + _mod.__package__ = '.'.join(_parts[:-1]) if len(_parts) > 1 else _mod_name + sys.modules[_mod_name] = _mod + if len(_parts) > 1: + setattr(sys.modules['.'.join(_parts[:-1])], _parts[-1], _mod) + _module_objs[_mod_name] = _mod + # Pass 2: execute source in all registered modules + for _mod_name, _mod_source in _EMBEDDED_MODULES.items(): + _code = compile(_mod_source, _mod_name.replace('.', '/') + '.py', 'exec') + exec(_code, _module_objs[_mod_name].__dict__) + + from helpers.utils import clean + + def my_component(name): + """Clean a name. + + Metadata: + version: 1.0 + """ + return clean(name) + + import argparse + _parser = argparse.ArgumentParser(prog='My component', description='Clean a name.') + _parser.add_argument("--name", dest="name", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("----output-paths", dest="_output_paths", type=str, nargs=1) + _parsed_args = vars(_parser.parse_args()) + _output_files = _parsed_args.pop("_output_paths", []) + + _outputs = my_component(**_parsed_args) + _outputs = [_outputs] + + _output_serializers = [ + _serialize_str, + ] + + import os + for idx, output_file in enumerate(_output_files): + try: + os.makedirs(os.path.dirname(output_file)) + except OSError: + pass + with open(output_file, 'w') as f: + f.write(_output_serializers[idx](_outputs[idx])) + args: + - --name + - inputValue: name + - '----output-paths' + - outputPath: Output diff --git a/tests/snapshots/component_generator/complete_generation.expected.yaml b/tests/snapshots/component_generator/complete_generation.expected.yaml new file mode 100644 index 0000000..4cbf839 --- /dev/null +++ b/tests/snapshots/component_generator/complete_generation.expected.yaml @@ -0,0 +1,121 @@ +name: Data Processor +description: Processes and validates input data. +metadata: + annotations: + cloud_pipelines.net: 'true' + components new regenerate python-function-component: 'true' + tangle_cli_generation_function_name: test_component + tangle_cli_generation_mode: inline + python_original_code_path: test_component.py + python_original_code: | + #!/usr/bin/env python3 + """Module docstring.""" + + def test_component(input_data: str, threshold: float = 0.5) -> dict: + """ + Processes and validates input data. + + Args: + input_data: The data to process + threshold: Processing threshold + + Returns: + Processing results as a dictionary + + Metadata: + Name: Data Processor + Version: 2.1.0 + updated_at: 2024-11-23T10:00:00Z + """ + return { + "processed": input_data.upper(), + "threshold_met": len(input_data) > threshold + } + + if __name__ == "__main__": + print(test_component("test", 0.3)) + version: 2.1.0 + updated_at: '2024-11-23T10:00:00Z' + component_yaml_path: test-component.yaml +inputs: +- name: input_data + type: String + description: The data to process +- name: threshold + type: Float + description: Processing threshold + default: '0.5' + optional: true +outputs: +- name: Output + type: JsonObject +implementation: + container: + image: test-image:latest + command: + - sh + - -ec + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + #!/usr/bin/env python3 + """Module docstring.""" + + def test_component(input_data, threshold = 0.5): + """ + Processes and validates input data. + + Args: + input_data: The data to process + threshold: Processing threshold + + Returns: + Processing results as a dictionary + + Metadata: + Name: Data Processor + Version: 2.1.0 + updated_at: 2024-11-23T10:00:00Z + """ + return { + "processed": input_data.upper(), + "threshold_met": len(input_data) > threshold + } + + import json + import argparse + _parser = argparse.ArgumentParser(prog='Data Processor', description='Processes and validates input data.') + _parser.add_argument("--input-data", dest="input_data", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--threshold", dest="threshold", type=float, required=False, default=argparse.SUPPRESS) + _parser.add_argument("----output-paths", dest="_output_paths", type=str, nargs=1) + _parsed_args = vars(_parser.parse_args()) + _output_files = _parsed_args.pop("_output_paths", []) + + _outputs = test_component(**_parsed_args) + _outputs = [_outputs] + + _output_serializers = [ + json.dumps, + ] + + import os + for idx, output_file in enumerate(_output_files): + try: + os.makedirs(os.path.dirname(output_file)) + except OSError: + pass + with open(output_file, 'w') as f: + f.write(_output_serializers[idx](_outputs[idx])) + args: + - --input-data + - inputValue: input_data + - if: + cond: + isPresent: threshold + then: + - --threshold + - inputValue: threshold + - '----output-paths' + - outputPath: Output diff --git a/tests/snapshots/component_generator/helper_functions.expected.yaml b/tests/snapshots/component_generator/helper_functions.expected.yaml new file mode 100644 index 0000000..5464512 --- /dev/null +++ b/tests/snapshots/component_generator/helper_functions.expected.yaml @@ -0,0 +1,86 @@ +name: My component +description: Format and return a name. +metadata: + annotations: + cloud_pipelines.net: 'true' + components new regenerate python-function-component: 'true' + tangle_cli_generation_function_name: my_component + tangle_cli_generation_mode: inline + python_original_code_path: my_component.py + python_original_code: |2 + + def format_name(name): + """Format a name for display.""" + return name.strip().title() + + def my_component(name: str) -> str: + """Format and return a name. + + Metadata: + version: 1.0 + """ + return format_name(name) + version: '1.0' + component_yaml_path: my-component.yaml +inputs: +- name: name + type: String +outputs: +- name: Output + type: String +implementation: + container: + image: python:3.12 + command: + - sh + - -ec + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + def _serialize_str(str_value) -> str: + if isinstance(str_value, str): + return str_value + else: + return str(str_value) + + def format_name(name): + """Format a name for display.""" + return name.strip().title() + + def my_component(name): + """Format and return a name. + + Metadata: + version: 1.0 + """ + return format_name(name) + + import argparse + _parser = argparse.ArgumentParser(prog='My component', description='Format and return a name.') + _parser.add_argument("--name", dest="name", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("----output-paths", dest="_output_paths", type=str, nargs=1) + _parsed_args = vars(_parser.parse_args()) + _output_files = _parsed_args.pop("_output_paths", []) + + _outputs = my_component(**_parsed_args) + _outputs = [_outputs] + + _output_serializers = [ + _serialize_str, + ] + + import os + for idx, output_file in enumerate(_output_files): + try: + os.makedirs(os.path.dirname(output_file)) + except OSError: + pass + with open(output_file, 'w') as f: + f.write(_output_serializers[idx](_outputs[idx])) + args: + - --name + - inputValue: name + - '----output-paths' + - outputPath: Output diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py new file mode 100644 index 0000000..c38a98d --- /dev/null +++ b/tests/test_api_cli.py @@ -0,0 +1,1875 @@ +import importlib +import json +import sys + +import httpx +import pytest + +from tangle_cli import api_cli, cli, cli_helpers, components_cli, published_components_cli + + +SCHEMA = { + "openapi": "3.1.0", + "paths": { + "/api/pipeline_runs/": { + "get": { + "tags": ["pipelineRuns"], + "summary": "List pipeline runs", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": {"type": "integer", "default": 20}, + }, + { + "name": "filter", + "in": "query", + "schema": {"type": "string"}, + }, + { + "name": "include_stats", + "in": "query", + "schema": {"type": "boolean"}, + }, + { + "name": "tag", + "in": "query", + "schema": {"type": "array", "items": {"type": "string"}}, + }, + ], + }, + "post": { + "tags": ["pipelineRuns"], + "summary": "Create pipeline run", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + } + } + }, + }, + }, + "/api/pipeline_runs/{id}": { + "get": { + "tags": ["pipelineRuns"], + "summary": "Get pipeline run", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + "/api/pipeline_runs/{id}/cancel": { + "post": { + "tags": ["pipelineRuns"], + "summary": "Cancel pipeline run", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + "/api/executions/{id}/details": { + "get": { + "tags": ["executions"], + "summary": "Execution details", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + "/api/components/{digest}": { + "get": { + "tags": ["components"], + "summary": "Get component", + "parameters": [ + { + "name": "digest", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + "/api/component_libraries/{id}": { + "get": { + "tags": ["components"], + "summary": "Get component library", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + "/api/published_components/": { + "get": {"tags": ["components"], "summary": "List published components"}, + "post": {"tags": ["components"], "summary": "Create published component"}, + }, + "/api/component_library_pins/me/": { + "get": {"tags": ["components"], "summary": "Get component library pins"}, + "put": {"tags": ["components"], "summary": "Set component library pins"}, + }, + "/api/users/me": { + "delete": {"tags": ["users"], "summary": "Delete current user"}, + "get": {"tags": ["users"], "summary": "Get current user"}, + }, + }, +} + + +def run_app(app, args): + with pytest.raises(SystemExit) as exc_info: + app(args) + assert exc_info.value.code == 0 + + +def lower_headers(headers): + return {name.lower(): value for name, value in dict(headers).items()} + + +def json_response(method, url, payload, status_code=200, headers=None): + return httpx.Response( + status_code, + json=payload, + headers=headers or {"Content-Type": "application/json"}, + request=httpx.Request(method, url), + ) + + +def text_response(method, url, text, status_code=200, headers=None): + return httpx.Response( + status_code, + text=text, + headers=headers or {"Content-Type": "text/plain"}, + request=httpx.Request(method, url), + ) + + +def test_dynamic_command_registration_from_openapi(capsys): + app = api_cli.build_app(SCHEMA) + + run_app(app, ["--help"]) + assert "pipeline-runs" in capsys.readouterr().out + + run_app(app, ["pipeline-runs", "--help"]) + output = capsys.readouterr().out + assert "list" in output + assert "create" in output + assert "get" in output + assert "cancel" in output + + run_app(app, ["executions", "--help"]) + assert "details" in capsys.readouterr().out + + run_app(app, ["components", "--help"]) + output = capsys.readouterr().out + assert "get" in output + + run_app(app, ["component-libraries", "--help"]) + assert "get" in capsys.readouterr().out + + run_app(app, ["published-components", "--help"]) + output = capsys.readouterr().out + assert "list" in output + assert "create" in output + + run_app(app, ["component-library-pins", "--help"]) + output = capsys.readouterr().out + assert "me" in output + assert "put-me" in output + + run_app(app, ["users", "--help"]) + output = capsys.readouterr().out + assert "me" in output + assert "delete-me" in output + + +def test_component_family_tag_collision_routes_by_resource_path(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["components", "get", "digest-1", "--base-url", "http://api.test"]) + assert requests[-1]["url"] == "http://api.test/api/components/digest-1" + + run_app( + app, + ["component-libraries", "get", "library-1", "--base-url", "http://api.test"], + ) + assert requests[-1]["url"] == "http://api.test/api/component_libraries/library-1" + + run_app(app, ["published-components", "list", "--base-url", "http://api.test"]) + assert requests[-1]["url"] == "http://api.test/api/published_components/" + + run_app(app, ["users", "me", "--base-url", "http://api.test"]) + assert requests[-1]["method"] == "GET" + assert requests[-1]["url"] == "http://api.test/api/users/me" + + run_app(app, ["users", "delete-me", "--base-url", "http://api.test"]) + assert requests[-1]["method"] == "DELETE" + assert requests[-1]["url"] == "http://api.test/api/users/me" + + +def test_root_app_exposes_api_and_sdk_groups(capsys): + app = cli.build_app() + + run_app(app, ["--help"]) + output = capsys.readouterr().out + assert "api" in output + assert "sdk" in output + assert "components" not in output + + run_app(app, ["sdk", "--help"]) + output = capsys.readouterr().out + assert "components" in output + assert "published-components" in output + + run_app(app, ["sdk", "components", "--help"]) + assert "Work with Tangle component definitions" in capsys.readouterr().out + + run_app(app, ["sdk", "published-components", "--help"]) + assert "Inspect and search published Tangle components" in capsys.readouterr().out + + with pytest.raises(SystemExit) as exc_info: + app(["components"]) + assert exc_info.value.code != 0 + + +def test_sdk_component_annotation_commands_preserve_help_and_error_behavior(capsys): + app = cli.build_app() + + run_app(app, ["sdk", "components", "annotations", "get"]) + assert "Gets annotation values" in capsys.readouterr().out + + run_app(app, ["sdk", "components", "annotations", "set"]) + assert "Sets annotation value" in capsys.readouterr().out + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "components", "annotations", "get", "foo"]) + assert exc_info.value.code == 1 + assert "Missing required argument" in capsys.readouterr().err + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "components", "annotations", "set", "foo", "key"]) + assert exc_info.value.code == 1 + assert "Missing required argument" in capsys.readouterr().err + + +def test_sdk_published_components_commands_call_inspection_helpers(monkeypatch, capsys): + app = cli.build_app() + fake_client = object() + client_calls = [] + + def fake_client_from_options(**kwargs): + client_calls.append(kwargs) + return fake_client + + monkeypatch.setattr( + published_components_cli, + "_client_from_options", + fake_client_from_options, + ) + from tangle_cli import component_inspector + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "search_components", + lambda self, **kwargs: {"client_ok": self._require_client() is fake_client, "search": kwargs}, + ) + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "inspect_by_name", + lambda self, name, **kwargs: { + "client_ok": self._require_client() is fake_client, + "name": name, + "inspect": kwargs, + }, + ) + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "inspect_by_digest", + lambda self, digest, **kwargs: { + "client_ok": self._require_client() is fake_client, + "digest": digest, + "inspect": kwargs, + }, + ) + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "get_standard_library", + lambda self: {"client_ok": self._require_client() is fake_client, "folders": []}, + ) + + run_app( + app, + [ + "sdk", + "published-components", + "search", + "demo", + "--include-deprecated", + "--published-by", + "user@example.com", + "--digest", + "sha256:abc", + "--base-url", + "https://api.test", + "-H", + "Cloud-Auth: token", + ], + ) + search_result = json.loads(capsys.readouterr().out) + assert search_result["client_ok"] is True + assert search_result["search"] == { + "name": "demo", + "include_deprecated": True, + "published_by": "user@example.com", + "digest": "sha256:abc", + } + assert client_calls[-1]["base_url"] == "https://api.test" + assert client_calls[-1]["header"] == ["Cloud-Auth: token"] + + run_app( + app, + [ + "sdk", + "published-components", + "inspect", + "demo", + "--all-versions", + "--include-deprecated", + "--full-spec", + ], + ) + name_result = json.loads(capsys.readouterr().out) + assert name_result["name"] == "demo" + assert name_result["inspect"]["include_all_versions"] is True + assert name_result["inspect"]["include_deprecated"] is True + assert name_result["inspect"]["full_spec"] is True + + run_app( + app, + [ + "sdk", + "published-components", + "inspect", + "--digest", + "sha256:def", + "--follow-deprecated", + ], + ) + digest_result = json.loads(capsys.readouterr().out) + assert digest_result["digest"] == "sha256:def" + assert digest_result["inspect"] == { + "full_spec": False, + "follow_deprecated": True, + } + + run_app(app, ["sdk", "published-components", "library"]) + library_result = json.loads(capsys.readouterr().out) + assert library_result == {"client_ok": True, "folders": []} + + +def test_sdk_published_components_search_uses_config_with_cli_precedence(monkeypatch, tmp_path, capsys): + app = cli.build_app() + config = tmp_path / "published.yaml" + config.write_text( + "name: from-config\n" + "include_deprecated: true\n" + "published_by: config@example.com\n" + "digest: sha256:config\n" + "base_url: https://config.example\n" + "token: config-token\n" + "auth_header: Bearer config-auth\n" + "header:\n" + " - 'X-Config: yes'\n", + encoding="utf-8", + ) + fake_client = object() + client_calls = [] + + def fake_client_from_options(**kwargs): + client_calls.append(kwargs) + return fake_client + + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + from tangle_cli import component_inspector + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "search_components", + lambda self, **kwargs: {"client_ok": self._require_client() is fake_client, "search": kwargs}, + ) + + run_app( + app, + [ + "sdk", + "published-components", + "search", + "from-cli", + "--config", + str(config), + "--digest", + "sha256:cli", + ], + ) + + result = json.loads(capsys.readouterr().out) + assert result["search"] == { + "name": "from-cli", + "include_deprecated": True, + "published_by": "config@example.com", + "digest": "sha256:cli", + } + assert client_calls[-1] == { + "base_url": "https://config.example", + "token": "config-token", + "auth_header": "Bearer config-auth", + "header": ["X-Config: yes"], + "include_env_credentials": False, + "command_name": "published-component commands", + } + + +def test_sdk_published_components_inspect_and_library_use_config(monkeypatch, tmp_path, capsys): + app = cli.build_app() + inspect_config = tmp_path / "inspect.yaml" + inspect_config.write_text( + "digest: sha256:config\n" + "follow_deprecated: true\n" + "full_spec: true\n" + "base_url: https://inspect.example\n", + encoding="utf-8", + ) + library_config = tmp_path / "library.json" + library_config.write_text(json.dumps({"base_url": "https://library.example"}), encoding="utf-8") + fake_client = object() + client_calls = [] + + def fake_client_from_options(**kwargs): + client_calls.append(kwargs) + return fake_client + + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + from tangle_cli import component_inspector + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "inspect_by_digest", + lambda self, digest, **kwargs: { + "client_ok": self._require_client() is fake_client, + "digest": digest, + "inspect": kwargs, + }, + ) + + monkeypatch.setattr( + component_inspector.ComponentInspector, + "get_standard_library", + lambda self: {"client_ok": self._require_client() is fake_client}, + ) + + run_app( + app, + ["sdk", "published-components", "inspect", "--config", str(inspect_config)], + ) + inspect_result = json.loads(capsys.readouterr().out) + assert inspect_result["digest"] == "sha256:config" + assert inspect_result["inspect"] == {"full_spec": True, "follow_deprecated": True} + assert client_calls[-1]["base_url"] == "https://inspect.example" + assert client_calls[-1]["include_env_credentials"] is False + + run_app( + app, + ["sdk", "published-components", "library", "--config", str(library_config)], + ) + library_result = json.loads(capsys.readouterr().out) + assert library_result == {"client_ok": True} + assert client_calls[-1]["base_url"] == "https://library.example" + assert client_calls[-1]["include_env_credentials"] is False + + +def test_lazy_tangle_api_client_uses_static_client(): + from tangle_cli.client import TangleApiClient + + proxy = cli_helpers.LazyTangleApiClient( + base_url="https://api.test", + token="token", + auth_header="Bearer auth", + header=["X-Test: yes"], + include_env_credentials=False, + command_name="published-component commands", + ) + client = proxy._get_client() + + assert isinstance(client, TangleApiClient) + assert client.base_url == "https://api.test" + assert client.token == "token" + assert client.auth_header == "Bearer auth" + assert client.header == ["X-Test: yes"] + assert client.include_env_credentials is False + + +def test_sdk_published_components_inspect_requires_name_or_digest(): + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "published-components", "inspect"]) + assert str(exc_info.value) == "Provide exactly one of NAME or --digest DIGEST" + + with pytest.raises(SystemExit) as exc_info: + app([ + "sdk", + "published-components", + "inspect", + "demo", + "--digest", + "sha256:abc", + ]) + assert str(exc_info.value) == "Provide exactly one of NAME or --digest DIGEST" + + +def test_importing_cli_modules_does_not_fetch_schema(monkeypatch, tmp_path): + calls = [] + + def fake_get(url, **kwargs): + calls.append({"method": "GET", "url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(sys, "argv", ["tangle", "api", "components", "list"]) + + import tangle_cli.cli as root_cli + + importlib.reload(api_cli) + importlib.reload(root_cli) + + assert calls == [] + + +def test_api_refresh_and_reset_cache_do_not_require_official_schema(monkeypatch, tmp_path): + def fail_load_schema(): # pragma: no cover - assertion helper + raise FileNotFoundError("missing tangle_api.schema") + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "refresh"]) + refresh_app = api_cli.build_app() + assert refresh_app is not None + + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "reset-cache"]) + reset_app = api_cli.build_app() + assert reset_app is not None + assert not api_cli._argv_requests_api_schema(api_cli.sys.argv) + assert not api_cli._argv_dispatches_dynamic_command(api_cli.sys.argv) + + +def test_api_help_without_official_schema_keeps_static_commands_unregistered(monkeypatch, tmp_path, capsys): + def fail_load_schema(): + raise FileNotFoundError("missing tangle_api.schema") + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "--help"]) + + app = api_cli.build_app() + run_app(app, ["--help"]) + + output = capsys.readouterr().out + assert "refresh" in output + assert "reset-cache" in output + assert "published-components" not in output + + +@pytest.mark.parametrize("api_tail", [["cached-extension"], ["--help"]]) +def test_auto_schema_loads_default_cache_without_ambient_auth(monkeypatch, api_tail): + for name in ( + "TANGLE_API_AUTH_HEADER", + "TANGLE_AUTH_HEADER", + "TANGLE_API_HEADERS", + "TANGLE_API_TOKEN", + "TANGLE_API_URL", + ): + monkeypatch.delenv(name, raising=False) + loaded_cache_urls = [] + official_schema = { + "openapi": "3.1.0", + "paths": {"/official": {"get": {"summary": "Official"}}}, + "components": {"schemas": {}}, + } + cached_schema = { + "openapi": "3.1.0", + "paths": {"/cached-extension": {"get": {"summary": "Cached extension"}}}, + "components": {"schemas": {"CachedOnly": {"type": "object"}}}, + } + + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", *api_tail]) + monkeypatch.setattr(api_cli, "default_base_url", lambda: "http://localhost:8000") + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", lambda: official_schema) + + def load_cached_schema(base_url): + loaded_cache_urls.append(base_url) + return cached_schema + + monkeypatch.setattr(api_cli, "load_cached_schema", load_cached_schema) + + schema = api_cli._schema_for_current_invocation() + + assert loaded_cache_urls == ["http://localhost:8000"] + assert schema is not None + assert "/official" in schema["paths"] + assert "/cached-extension" in schema["paths"] + + +def test_api_help_with_ambient_auth_does_not_probe_implicit_localhost( + monkeypatch, tmp_path, capsys +): + def fail_load_cached_schema(base_url): # pragma: no cover - assertion helper + raise AssertionError(f"must not inspect cache for implicit base URL {base_url}") + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setattr(api_cli, "load_cached_schema", fail_load_cached_schema) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "--help"]) + + app = api_cli.build_app() + run_app(app, ["--help"]) + + output = capsys.readouterr().out + assert "refresh" in output + assert "published-components" in output + + +@pytest.mark.parametrize( + ("api_tail", "app_args", "expected"), + [ + (["published-components", "--help"], ["published-components", "--help"], "list"), + (["published-components", "list", "--help"], ["published-components", "list", "--help"], "--name-substring"), + ( + ["published-components", "--schema-source", "official", "--help"], + ["published-components", "--schema-source", "official", "--help"], + "list", + ), + ], +) +def test_nested_api_help_with_ambient_auth_does_not_probe_implicit_localhost( + monkeypatch, + tmp_path, + capsys, + api_tail, + app_args, + expected, +): + def fail_load_cached_schema(base_url): # pragma: no cover - assertion helper + raise AssertionError(f"must not inspect cache for implicit base URL {base_url}") + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setattr(api_cli, "load_cached_schema", fail_load_cached_schema) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", *api_tail]) + + app = api_cli.build_app() + run_app(app, app_args) + + assert expected in capsys.readouterr().out + + +def test_real_auto_api_command_with_ambient_auth_uses_transport_guard(monkeypatch): + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "cached-extension"]) + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", lambda: SCHEMA) + + def fail_load_cached_schema(base_url): # pragma: no cover - assertion helper + raise AssertionError(f"cache should not load before auth guard, got {base_url}") + + monkeypatch.setattr(api_cli, "load_cached_schema", fail_load_cached_schema) + + with pytest.raises(SystemExit, match="TANGLE_API_URL is required"): + api_cli._schema_for_current_invocation() + + +@pytest.mark.parametrize( + "api_tail", + [ + ["--schema-source", "cache", "--help"], + ["--schema-source", "cache", "published-components", "--help"], + ["published-components", "--schema-source", "cache", "--help"], + ], +) +def test_cache_help_with_ambient_auth_and_no_base_url_uses_transport_guard( + monkeypatch, + api_tail, +): + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", *api_tail]) + + def fail_load_cached_schema(base_url): # pragma: no cover - assertion helper + raise AssertionError(f"cache should not load before auth guard, got {base_url}") + + monkeypatch.setattr(api_cli, "load_cached_schema", fail_load_cached_schema) + + with pytest.raises(SystemExit, match="TANGLE_API_URL is required"): + api_cli._schema_for_current_invocation() + + +def test_generated_command_with_ambient_auth_still_requires_explicit_base_url(monkeypatch): + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "pipeline-runs", "list"], + ) + + app = api_cli.build_app(SCHEMA) + with pytest.raises(SystemExit, match="refusing to send credentials to default"): + app(["pipeline-runs", "list"]) + + +def test_cold_schema_bootstrap_forwards_cli_auth_flags(monkeypatch, tmp_path): + fetched = [] + + def fail_load_schema(): + raise FileNotFoundError("missing tangle_api.schema") + + def fake_load_or_fetch_schema(base_url, **kwargs): + fetched.append({"base_url": base_url, **kwargs}) + return { + "openapi": "3.1.0", + "paths": {"/cached-extension": {"get": {"summary": "Cached extension"}}}, + "components": {"schemas": {}}, + } + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + monkeypatch.setattr(api_cli, "load_or_fetch_schema", fake_load_or_fetch_schema) + monkeypatch.setattr( + api_cli.sys, + "argv", + [ + "tangle", + "api", + "cached-extension", + "--base-url", + "http://api.test", + "--token", + "cli-token", + "--auth-header", + "Basic abc", + "-H", + "X-Trace: 1", + "--header", + "X-Other: 2", + ], + ) + + schema = api_cli._schema_for_current_invocation() + + assert schema["paths"] == {"/cached-extension": {"get": {"summary": "Cached extension"}}} + assert fetched == [ + { + "base_url": "http://api.test", + "token": "cli-token", + "auth_header": "Basic abc", + "header": ["X-Trace: 1", "X-Other: 2"], + "include_env_credentials": True, + } + ] + + +def test_cold_schema_bootstrap_forwards_config_auth_flags(monkeypatch, tmp_path): + fetched = [] + config = tmp_path / "api-config.yaml" + config.write_text( + "\n".join( + [ + "base_url: http://api.test", + "token: config-token", + "auth_header: Basic config-auth", + "header:", + " - 'X-Config: yes'", + "", + ] + ), + encoding="utf-8", + ) + + def fail_load_schema(): + raise FileNotFoundError("missing tangle_api.schema") + + def fake_load_or_fetch_schema(base_url, **kwargs): + fetched.append({"base_url": base_url, **kwargs}) + return { + "openapi": "3.1.0", + "paths": {"/cached-extension": {"get": {"summary": "Cached extension"}}}, + "components": {"schemas": {}}, + } + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.setenv("TANGLE_API_TOKEN", "env-token") + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Bearer env-auth") + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + monkeypatch.setattr(api_cli, "load_or_fetch_schema", fake_load_or_fetch_schema) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "cached-extension", "--config", str(config)], + ) + + schema = api_cli._schema_for_current_invocation() + + assert schema["paths"] == {"/cached-extension": {"get": {"summary": "Cached extension"}}} + assert fetched == [ + { + "base_url": "http://api.test", + "token": "config-token", + "auth_header": "Basic config-auth", + "header": ["X-Config: yes"], + "include_env_credentials": False, + } + ] + + +def test_cold_schema_bootstrap_suppresses_env_auth_for_config_base_url(monkeypatch, tmp_path): + fetched = [] + config = tmp_path / "api-config.yaml" + config.write_text("base_url: http://api.test\n", encoding="utf-8") + + def fail_load_schema(): + raise FileNotFoundError("missing tangle_api.schema") + + def fake_load_or_fetch_schema(base_url, **kwargs): + fetched.append({"base_url": base_url, **kwargs}) + return { + "openapi": "3.1.0", + "paths": {"/cached-extension": {"get": {"summary": "Cached extension"}}}, + "components": {"schemas": {}}, + } + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.setenv("TANGLE_API_TOKEN", "env-token") + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Bearer env-auth") + monkeypatch.setenv("TANGLE_API_HEADERS", "X-Env: secret") + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + monkeypatch.setattr(api_cli, "load_or_fetch_schema", fake_load_or_fetch_schema) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "cached-extension", "--config", str(config)], + ) + + schema = api_cli._schema_for_current_invocation() + + assert schema["paths"] == {"/cached-extension": {"get": {"summary": "Cached extension"}}} + assert fetched == [ + { + "base_url": "http://api.test", + "token": None, + "auth_header": None, + "header": [], + "include_env_credentials": False, + } + ] + + +def test_official_static_command_without_schema_fails_with_actionable_error(monkeypatch, tmp_path): + def fail_load_schema(): + raise FileNotFoundError("missing tangle_api.schema") + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "--help"], + ) + + with pytest.raises(SystemExit) as exc_info: + 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 "--schema-source cache" in message + + +def test_cache_schema_source_does_not_require_official_schema(monkeypatch, tmp_path, capsys): + def fail_load_schema(): + raise FileNotFoundError("missing tangle_api.schema") + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "list", "--schema-source", "cache", "--help"], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "list", "--schema-source", "cache", "--help"]) + + output = capsys.readouterr().out + assert "--cached-only" in output + + +def test_non_api_root_command_does_not_fetch_when_argument_value_is_api( + monkeypatch, tmp_path +): + calls = [] + + def fake_get(url, **kwargs): + calls.append({"method": "GET", "url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "sdk", "components", "annotations", "set", "foo", "api"], + ) + + api_cli.build_app() + + assert calls == [] + assert not api_cli._argv_requests_api_schema(api_cli.sys.argv) + assert not api_cli._argv_dispatches_dynamic_command(api_cli.sys.argv) + + +def test_cold_cache_static_command_dispatch_uses_bundled_schema(monkeypatch, tmp_path): + gets = [] + requests = [] + + def fake_get(url, **kwargs): + gets.append({"method": "GET", "url": url, **kwargs}) + request = httpx.Request("GET", url) + raise httpx.ConnectError("backend unavailable", request=request) + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"published_components": []}) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + monkeypatch.setattr( + api_cli.sys, + "argv", + [ + "tangle", + "api", + "published-components", + "list", + "--name-substring", + "scrape v2", + "--base-url", + "http://api.test", + ], + ) + + app = api_cli.build_app() + assert gets == [] + + run_app( + app, + [ + "published-components", + "list", + "--name-substring", + "scrape v2", + "--base-url", + "http://api.test", + ], + ) + assert requests[-1]["method"] == "GET" + assert requests[-1]["url"] == "http://api.test/api/published_components/?name_substring=scrape+v2" + + +def test_cold_cache_api_help_shows_static_resource_groups_and_refresh( + monkeypatch, tmp_path, capsys +): + gets = [] + + def fake_get(url, **kwargs): + gets.append({"method": "GET", "url": url, **kwargs}) + request = httpx.Request("GET", url) + raise httpx.ConnectError("backend unavailable", request=request) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "--help"]) + + app = api_cli.build_app() + run_app(app, ["--help"]) + + output = capsys.readouterr().out + assert gets == [] + assert "published-components" in output + assert "pipeline-runs" in output + assert "refresh" in output + assert "Unknown command" not in output + + +def _tangle_like_schema_with_published_component_extensions() -> dict: + schema = json.loads(json.dumps(SCHEMA)) + schema["paths"]["/api/published_components/"]["get"] = { + "tags": ["components"], + "summary": "Cached drifted list published components", + "parameters": [ + { + "name": "cached_only", + "in": "query", + "schema": {"type": "string"}, + } + ], + } + schema["paths"]["/api/published_components/experimental/search"] = { + "post": {"tags": ["components"], "summary": "Search Components"} + } + schema["paths"]["/api/published_components/experimental/search/schema"] = { + "get": {"tags": ["components"], "summary": "Get Component Search Schema"} + } + return schema + + +def test_no_cache_default_schema_source_shows_official_static_only( + monkeypatch, tmp_path, capsys +): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "--help"], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "--help"]) + + output = capsys.readouterr().out + assert "list" in output + assert "experimental-search" not in output + assert "experimental-search-schema" not in output + + +def test_default_schema_source_merges_cached_backend_extensions( + monkeypatch, tmp_path, capsys +): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "--help"], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "--help"]) + + output = capsys.readouterr().out + assert "list" in output + assert "experimental-search" in output + assert "experimental-search-schema" in output + + +def test_default_schema_source_preserves_official_operation_on_cache_collision( + monkeypatch, tmp_path, capsys +): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "list", "--help"], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "list", "--help"]) + + output = capsys.readouterr().out + assert "--name-substring" in output + assert "--cached-only" not in output + + +def test_official_schema_source_hides_cached_extensions(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "--schema-source", "official", "--help"], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "--schema-source", "official", "--help"]) + + output = capsys.readouterr().out + assert "list" in output + assert "experimental-search" not in output + assert "experimental-search-schema" not in output + + +def test_cache_schema_source_uses_raw_cached_schema(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "list", "--schema-source", "cache", "--help"], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "list", "--schema-source", "cache", "--help"]) + + output = capsys.readouterr().out + assert "--cached-only" in output + + +def test_explicit_cache_schema_source_requires_cached_schema(monkeypatch, tmp_path): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "--schema-source", "cache", "--help"], + ) + + with pytest.raises(SystemExit) as exc_info: + api_cli.build_app() + + assert "No cached OpenAPI schema for http://api.test" in str(exc_info.value) + + + +def test_reset_cache_deletes_existing_cached_schema(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + api_cli.write_cached_schema(SCHEMA, "http://api.test") + path = api_cli.cache_path("http://api.test") + assert path.exists() + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "reset-cache", "--base-url", "http://api.test"], + ) + + app = api_cli.build_app() + run_app(app, ["reset-cache", "--base-url", "http://api.test"]) + + output = capsys.readouterr().out + assert not path.exists() + assert "Deleted cached OpenAPI schema for http://api.test" in output + assert str(path) in output + + +def test_reset_cache_reports_noop_when_cache_absent(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + path = api_cli.cache_path("http://api.test") + assert not path.exists() + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "reset-cache", "--base-url", "http://api.test"], + ) + + app = api_cli.build_app() + run_app(app, ["reset-cache", "--base-url", "http://api.test"]) + + output = capsys.readouterr().out + assert "No cached OpenAPI schema for http://api.test" in output + assert str(path) in output + + +def test_refresh_uses_config_with_cli_precedence(monkeypatch, tmp_path, capsys): + calls = [] + config = tmp_path / "refresh.yaml" + config.write_text( + "base_url: https://config.example\n" + "token: config-token\n" + "auth_header: Bearer config-auth\n" + "header:\n" + " - 'X-Config: yes'\n", + encoding="utf-8", + ) + + def fake_refresh_schema(base_url, token, header, auth_header, **kwargs): + calls.append({ + "base_url": base_url, + "token": token, + "header": header, + "auth_header": auth_header, + **kwargs, + }) + path = tmp_path / "cached.json" + return SCHEMA, path + + monkeypatch.setattr(api_cli, "refresh_schema", fake_refresh_schema) + app = api_cli.build_app(SCHEMA) + + run_app( + app, + [ + "refresh", + "--config", + str(config), + "--base-url", + "https://cli.example", + ], + ) + + assert calls == [{ + "base_url": "https://cli.example", + "token": "config-token", + "header": ["X-Config: yes"], + "auth_header": "Bearer config-auth", + "include_env_credentials": True, + }] + assert "Cached OpenAPI schema for https://cli.example" in capsys.readouterr().out + + +def test_refresh_config_base_url_suppresses_env_credentials(monkeypatch, tmp_path): + requests = [] + config = tmp_path / "refresh.yaml" + config.write_text("base_url: http://config.test\n", encoding="utf-8") + + def fake_get(url, **kwargs): + requests.append({"url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.setenv("TANGLE_API_TOKEN", "env-token") + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Bearer env-auth") + monkeypatch.setenv("TANGLE_API_HEADERS", "X-Env: secret") + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["refresh", "--config", str(config)]) + + assert requests[-1]["url"] == "http://config.test/openapi.json" + assert "Authorization" not in requests[-1]["headers"] + assert "X-Env" not in requests[-1]["headers"] + + +def test_refresh_config_base_url_preserves_config_auth(monkeypatch, tmp_path): + requests = [] + config = tmp_path / "refresh.yaml" + config.write_text( + "base_url: http://config.test\n" + "token: config-token\n" + "auth_header: Basic config-auth\n" + "header:\n" + " - 'X-Config: yes'\n", + encoding="utf-8", + ) + + def fake_get(url, **kwargs): + requests.append({"url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.setenv("TANGLE_API_TOKEN", "env-token") + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Bearer env-auth") + monkeypatch.setenv("TANGLE_API_HEADERS", "X-Env: secret") + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["refresh", "--config", str(config)]) + + assert requests[-1]["url"] == "http://config.test/openapi.json" + assert requests[-1]["headers"]["Authorization"] == "Basic config-auth" + assert requests[-1]["headers"]["X-Config"] == "yes" + assert "X-Env" not in requests[-1]["headers"] + + +def test_reset_cache_uses_config_base_url(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + config = tmp_path / "reset.yaml" + config.write_text("base_url: https://config.example\n", encoding="utf-8") + api_cli.write_cached_schema(SCHEMA, "https://config.example") + path = api_cli.cache_path("https://config.example") + assert path.exists() + + app = api_cli.build_app(SCHEMA) + run_app(app, ["reset-cache", "--config", str(config)]) + + output = capsys.readouterr().out + assert not path.exists() + assert "Deleted cached OpenAPI schema for https://config.example" in output + + +def test_config_can_select_cache_schema_at_build_time(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + config = tmp_path / "schema-source.yaml" + config.write_text( + "base_url: http://api.test\n" + "schema_source: cache\n", + encoding="utf-8", + ) + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + monkeypatch.setattr( + api_cli.sys, + "argv", + [ + "tangle", + "api", + "published-components", + "list", + "--config", + str(config), + "--help", + ], + ) + + app = api_cli.build_app() + run_app(app, ["published-components", "list", "--config", str(config), "--help"]) + + output = capsys.readouterr().out + assert "--cached-only" in output + + +def test_reset_cache_returns_auto_mode_to_official_only(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setenv("TANGLE_API_URL", "http://api.test") + api_cli.write_cached_schema( + _tangle_like_schema_with_published_component_extensions(), + "http://api.test", + ) + + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "reset-cache"]) + reset_app = api_cli.build_app() + run_app(reset_app, ["reset-cache"]) + capsys.readouterr() + + monkeypatch.setattr( + api_cli.sys, + "argv", + ["tangle", "api", "published-components", "--help"], + ) + help_app = api_cli.build_app() + run_app(help_app, ["published-components", "--help"]) + + output = capsys.readouterr().out + assert "list" in output + assert "experimental-search" not in output + assert "experimental-search-schema" not in output + +def test_refresh_remains_available_on_cold_cache(monkeypatch, tmp_path, capsys): + def fake_get(url, **kwargs): + request = httpx.Request("GET", url) + raise httpx.ConnectError("backend unavailable", request=request) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "refresh", "--help"]) + + app = api_cli.build_app() + run_app(app, ["refresh", "--help"]) + + output = capsys.readouterr().out + assert "Fetch /openapi.json" in output + assert "--base-url" in output + + +def test_optional_query_params_parse_and_can_be_omitted(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["pipeline-runs", "list", "--base-url", "http://api.test"]) + assert requests[-1]["url"] == "http://api.test/api/pipeline_runs/" + + run_app( + app, + [ + "pipeline-runs", + "list", + "--filter", + "active", + "--include-stats", + "--tag", + "a", + "--tag", + "b", + "--base-url", + "http://api.test", + ], + ) + assert ( + requests[-1]["url"] + == "http://api.test/api/pipeline_runs/?filter=active&include_stats=True&tag=a&tag=b" + ) + + +def test_cli_body_at_file_reference_expands_json_file(monkeypatch, tmp_path): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + body_path = tmp_path / "body.json" + body_path.write_text('{"name":"from-file"}', encoding="utf-8") + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app( + app, + [ + "pipeline-runs", + "create", + "--body", + f"@{body_path}", + "--base-url", + "http://api.test", + ], + ) + + assert json.loads(requests[-1]["content"].decode()) == {"name": "from-file"} + + +def test_body_json_can_satisfy_required_simple_body_fields(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app( + app, + [ + "pipeline-runs", + "create", + "--body", + '{"name":"demo"}', + "--base-url", + "http://api.test", + ], + ) + + assert requests[-1]["url"] == "http://api.test/api/pipeline_runs/" + assert json.loads(requests[-1]["content"].decode()) == {"name": "demo"} + + +def test_dynamic_command_uses_config_with_cli_precedence(monkeypatch, tmp_path): + requests = [] + config = tmp_path / "operation.yaml" + config.write_text( + "base_url: http://config.test\n" + "token: config-token\n" + "filter: active\n" + "limit: 9\n" + "include_stats: true\n" + "tag:\n" + " - config-tag\n", + encoding="utf-8", + ) + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app( + app, + [ + "pipeline-runs", + "list", + "--config", + str(config), + "--limit", + "3", + ], + ) + + assert ( + requests[-1]["url"] + == "http://config.test/api/pipeline_runs/?limit=3&filter=active&include_stats=True&tag=config-tag" + ) + assert requests[-1]["headers"]["Authorization"] == "Bearer config-token" + + +def test_dynamic_command_required_path_and_body_can_come_from_config(monkeypatch, tmp_path): + requests = [] + get_config = tmp_path / "get.yaml" + get_config.write_text( + "base_url: http://config.test\n" + "id: run/1\n", + encoding="utf-8", + ) + create_config = tmp_path / "create.yaml" + create_config.write_text( + "base_url: http://config.test\n" + "body:\n" + " name: from-config\n", + encoding="utf-8", + ) + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["pipeline-runs", "get", "--config", str(get_config)]) + assert requests[-1]["url"] == "http://config.test/api/pipeline_runs/run%2F1" + + run_app(app, ["pipeline-runs", "create", "--config", str(create_config)]) + assert requests[-1]["url"] == "http://config.test/api/pipeline_runs/" + assert json.loads(requests[-1]["content"].decode()) == {"name": "from-config"} + + +def test_config_body_at_file_reference_is_literal_and_suppresses_env_auth(monkeypatch, tmp_path): + requests = [] + body_path = tmp_path / "body.json" + body_path.write_text('{"name":"from-file"}', encoding="utf-8") + config = tmp_path / "create.yaml" + config.write_text( + f"base_url: http://config.test\nbody: '@{body_path}'\n", + encoding="utf-8", + ) + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setenv("TANGLE_API_TOKEN", "env-token") + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Bearer env-auth") + monkeypatch.setenv("TANGLE_API_HEADERS", "X-Env: secret") + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["pipeline-runs", "create", "--config", str(config)]) + + assert requests[-1]["url"] == "http://config.test/api/pipeline_runs/" + assert json.loads(requests[-1]["content"].decode()) == f"@{body_path}" + assert "Authorization" not in requests[-1]["headers"] + assert "X-Env" not in requests[-1]["headers"] + + +def test_dynamic_command_invocation_maps_path_query_and_body(monkeypatch, capsys): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + run_app(app, ["pipeline-runs", "list", "--limit", "3", "--base-url", "http://api.test"]) + assert requests[-1]["url"] == "http://api.test/api/pipeline_runs/?limit=3" + assert requests[-1]["method"] == "GET" + assert requests[-1]["timeout"] == api_cli.DEFAULT_TIMEOUT_SECONDS + + run_app( + app, + [ + "pipeline-runs", + "create", + "--name", + "demo", + "--base-url", + "http://api.test", + "--token", + "secret", + ], + ) + assert requests[-1]["url"] == "http://api.test/api/pipeline_runs/" + assert requests[-1]["method"] == "POST" + assert requests[-1]["headers"]["Authorization"] == "Bearer secret" + assert json.loads(requests[-1]["content"].decode()) == {"name": "demo"} + + run_app(app, ["pipeline-runs", "get", "run/1", "--base-url", "http://api.test"]) + assert requests[-1]["url"] == "http://api.test/api/pipeline_runs/run%2F1" + + assert '"ok": true' in capsys.readouterr().out + + +def test_refresh_http_error_does_not_echo_response_body(monkeypatch): + def fake_get(url, **kwargs): + response = text_response( + url="http://api.test/openapi.json", + method="GET", + text="secret-token", + status_code=401, + ) + raise httpx.HTTPStatusError( + "client error", request=response.request, response=response + ) + + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + app = api_cli.build_app(SCHEMA) + + with pytest.raises(SystemExit) as exc_info: + app(["refresh", "--base-url", "http://api.test"]) + + message = str(exc_info.value) + assert "HTTP 401 Unauthorized" in message + assert "secret-token" not in message + + +def test_nested_refs_are_resolved_for_simple_array_body_fields(monkeypatch): + schema = { + "openapi": "3.1.0", + "paths": { + "/api/items/": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Name" + }, + } + }, + } + } + } + } + } + } + }, + "components": {"schemas": {"Name": {"type": "string"}}}, + } + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(schema) + + run_app( + app, ["items", "create", "--names", "alice", "--base-url", "http://api.test"] + ) + + assert json.loads(requests[-1]["content"].decode()) == {"names": ["alice"]} + + +def test_http_error_prints_body_and_exits_with_status(monkeypatch, capsys): + def fake_request(method, url, **kwargs): + return text_response(method, url, "not authorized", status_code=401) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + with pytest.raises(SystemExit) as exc_info: + app(["pipeline-runs", "list", "--base-url", "http://api.test"]) + + assert exc_info.value.code == 401 + assert "not authorized" in capsys.readouterr().err + + +def test_network_error_message_includes_url(monkeypatch): + def fake_request(method, url, **kwargs): + request = httpx.Request(method, url) + raise httpx.ConnectError("connection refused", request=request) + + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + app = api_cli.build_app(SCHEMA) + + with pytest.raises(SystemExit) as exc_info: + app(["pipeline-runs", "list", "--base-url", "http://api.test"]) + + message = str(exc_info.value) + assert "http://api.test/api/pipeline_runs/" in message + assert "connection refused" in message + + +def test_custom_headers_apply_to_schema_fetch_and_generated_requests(monkeypatch): + gets = [] + requests = [] + + def fake_get(url, **kwargs): + gets.append({"method": "GET", "url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, SCHEMA) + + monkeypatch.setenv( + "TANGLE_API_HEADERS", + json.dumps({"Cloud-Auth": "env-value", "X-Api-Key": "env-key"}), + ) + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Basic env-auth") + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + monkeypatch.setattr(api_cli.httpx, "request", fake_request) + + api_cli.fetch_schema("http://api.test") + env_headers = lower_headers(gets[-1]["headers"]) + assert env_headers["authorization"] == "Basic env-auth" + assert env_headers["cloud-auth"] == "env-value" + + api_cli.fetch_schema( + "http://api.test", + token="bearer-value", + header=["Cloud-Auth: cli-value"], + auth_header="Basic cli-auth", + ) + schema_headers = lower_headers(gets[-1]["headers"]) + assert schema_headers["authorization"] == "Basic cli-auth" + assert schema_headers["cloud-auth"] == "cli-value" + assert schema_headers["x-api-key"] == "env-key" + + app = api_cli.build_app(SCHEMA) + run_app( + app, + [ + "pipeline-runs", + "list", + "--base-url", + "http://api.test", + "--token", + "bearer-value", + "--auth-header", + "Basic cli-auth", + "--header", + "Cloud-Auth: cli-value", + ], + ) + request_headers = lower_headers(requests[-1]["headers"]) + assert request_headers["authorization"] == "Basic cli-auth" + assert request_headers["cloud-auth"] == "cli-value" + assert request_headers["x-api-key"] == "env-key" + + +def test_invalid_header_errors_do_not_echo_secret(): + with pytest.raises(SystemExit) as exc_info: + api_cli._parse_header_entries(["Cloud-Auth super-secret"], "--header") + assert "super-secret" not in str(exc_info.value) + + with pytest.raises(SystemExit) as exc_info: + api_cli._normalize_auth_header("Basic bad\nsecret", "--auth-header") + assert "secret" not in str(exc_info.value) + + +def test_components_annotation_commands_show_help_with_no_args(capsys): + run_app(components_cli.app, ["annotations", "get"]) + assert "Gets annotation values" in capsys.readouterr().out + + run_app(components_cli.app, ["annotations", "set"]) + assert "Sets annotation value" in capsys.readouterr().out + + +def test_components_annotation_commands_error_with_partial_args(capsys): + with pytest.raises(SystemExit) as exc_info: + components_cli.app(["annotations", "get", "foo"]) + assert exc_info.value.code == 1 + assert "Missing required argument" in capsys.readouterr().err + + with pytest.raises(SystemExit) as exc_info: + components_cli.app(["annotations", "set", "foo", "key"]) + assert exc_info.value.code == 1 + assert "Missing required argument" in capsys.readouterr().err + + +def test_default_cache_dir_uses_platformdirs_and_env_override(monkeypatch, tmp_path): + platform_cache = tmp_path / "platform-cache" + explicit_cache = tmp_path / "explicit-cache" + monkeypatch.delenv("TANGLE_CLI_CACHE_DIR", raising=False) + monkeypatch.setattr( + api_cli.platformdirs, + "user_cache_dir", + lambda appname, appauthor: str(platform_cache), + ) + + assert api_cli.default_cache_dir() == platform_cache / "openapi" + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(explicit_cache)) + assert api_cli.default_cache_dir() == explicit_cache + + +def test_schema_cache_avoids_repeated_fetch(monkeypatch, tmp_path): + calls = [] + + def fake_get(url, **kwargs): + calls.append({"method": "GET", "url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(api_cli.httpx, "get", fake_get) + + first = api_cli.load_or_fetch_schema("http://api.test") + second = api_cli.load_or_fetch_schema("http://api.test") + + assert first == SCHEMA + assert second == SCHEMA + assert len(calls) == 1 diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py new file mode 100644 index 0000000..c3ea83a --- /dev/null +++ b/tests/test_api_transport.py @@ -0,0 +1,243 @@ +from types import SimpleNamespace + +import httpx +import pytest + +from tangle_cli.api_transport import ( + _redact_headers, + build_operation_request, + default_base_url, + request_operation, + tangle_verbose_enabled, +) + + +def _operation(path: str, *, method: str = "GET", has_request_body: bool = False) -> SimpleNamespace: + return SimpleNamespace( + method=method, + path=path, + parameters=[], + group_name="test", + command_name="op", + has_request_body=has_request_body, + ) + + +@pytest.mark.parametrize( + "env_name", + [ + "TANGLE_API_AUTH_HEADER", + "TANGLE_AUTH_HEADER", + "TANGLE_API_HEADERS", + "TANGLE_API_TOKEN", + ], +) +def test_default_base_url_rejects_ambient_auth_for_implicit_localhost( + monkeypatch: pytest.MonkeyPatch, + env_name: str, +) -> None: + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setenv(env_name, "secret") + + with pytest.raises(SystemExit, match="refusing to send credentials to default"): + default_base_url() + + +def test_default_base_url_allows_implicit_localhost_without_ambient_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("TANGLE_API_URL", raising=False) + for env_name in ( + "TANGLE_API_AUTH_HEADER", + "TANGLE_AUTH_HEADER", + "TANGLE_API_HEADERS", + "TANGLE_API_TOKEN", + ): + monkeypatch.delenv(env_name, raising=False) + + assert default_base_url() == "http://localhost:8000" + + +def test_default_base_url_allows_explicit_api_url_with_ambient_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TANGLE_API_URL", "https://api.tangle.test") + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + + assert default_base_url() == "https://api.tangle.test" + + +def test_build_operation_request_allows_explicit_localhost_with_ambient_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("TANGLE_API_URL", raising=False) + monkeypatch.setenv("TANGLE_API_TOKEN", "secret-token") + + _method, url, headers, _content = build_operation_request( + _operation("/health"), + {}, + base_url="http://localhost:8000", + ) + + assert url == "http://localhost:8000/health" + assert headers["Authorization"] == "Bearer secret-token" + + +@pytest.mark.parametrize("value", [None, "", "0", "false", "False", "no", "off"]) +def test_tangle_verbose_false_values(monkeypatch: pytest.MonkeyPatch, value: str | None) -> None: + if value is None: + monkeypatch.delenv("TANGLE_VERBOSE", raising=False) + else: + monkeypatch.setenv("TANGLE_VERBOSE", value) + + assert tangle_verbose_enabled() is False + + +@pytest.mark.parametrize("value", ["1", "true", "yes", "on"]) +def test_tangle_verbose_truthy_values(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("TANGLE_VERBOSE", value) + + assert tangle_verbose_enabled() is True + + +def test_request_operation_does_not_log_bodies_when_verbose_false( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("TANGLE_VERBOSE", "0") + + def fake_request(*args, **kwargs): + return httpx.Response( + 200, + json={"id": "run-1", "secret": "response-secret"}, + request=httpx.Request("POST", "https://api.test/api/pipeline_runs/"), + ) + + monkeypatch.setattr("tangle_cli.api_transport.httpx.request", fake_request) + + request_operation( + _operation("/api/pipeline_runs/", method="POST", has_request_body=True), + {}, + base_url="https://api.test", + auth_header="Bearer request-secret", + body={"name": "demo", "token": "request-token"}, + ) + + assert capsys.readouterr().err == "" + + +def test_redact_headers_matches_auth_segments_without_redacting_author_names() -> None: + headers = {"X-Gateway-Auth": "secret", "X-Author": "alice"} + + assert _redact_headers(headers) == {"X-Gateway-Auth": "", "X-Author": "alice"} + + +def test_request_operation_verbose_env_logs_redacted_body( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("TANGLE_VERBOSE", "1") + + def fake_request(*args, **kwargs): + return httpx.Response( + 200, + json={ + "id": "run-1", + "secret": "response-secret", + "signed_url": "https://storage.test/object?X-Goog-Signature=response-signature", + }, + headers={"X-Api-Key": "response-key"}, + request=httpx.Request("POST", "https://api.test/api/pipeline_runs/"), + ) + + monkeypatch.setattr("tangle_cli.api_transport.httpx.request", fake_request) + + request_operation( + _operation("/api/pipeline_runs/", method="POST", has_request_body=True), + {}, + base_url="https://api.test", + auth_header="Bearer request-secret", + header_entries=["Cloud-Auth: cloud-secret", "X-Gateway-Auth: gateway-secret"], + body={"name": "demo", "token": "request-token"}, + ) + + logs = capsys.readouterr().err + assert "[tangle-api] request: POST https://api.test/api/pipeline_runs/" in logs + assert "request body" in logs + assert "response body" in logs + assert "demo" in logs + assert "run-1" in logs + assert "request-secret" not in logs + assert "cloud-secret" not in logs + assert "gateway-secret" not in logs + assert "request-token" not in logs + assert "response-secret" not in logs + assert "response-key" not in logs + assert "response-signature" not in logs + + +def test_request_operation_verbose_env_redacts_opaque_component_text( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("TANGLE_VERBOSE", "1") + + def fake_request(*args, **kwargs): + return httpx.Response( + 200, + json={"id": "component-1", "text": "response-yaml-with-secret-token"}, + request=httpx.Request("POST", "https://api.test/api/components/"), + ) + + monkeypatch.setattr("tangle_cli.api_transport.httpx.request", fake_request) + + request_operation( + _operation("/api/components/", method="POST", has_request_body=True), + {}, + base_url="https://api.test", + auth_header="Bearer request-secret", + body={ + "name": "demo-component", + "text": "component:\n env:\n TOKEN: hard-coded-component-secret\n", + }, + ) + + logs = capsys.readouterr().err + assert "demo-component" in logs + assert "" in logs + assert "hard-coded-component-secret" not in logs + assert "response-yaml-with-secret-token" not in logs + + +def test_build_operation_request_rejects_absolute_url_paths() -> None: + with pytest.raises(ValueError, match="must be relative"): + build_operation_request( + _operation("https://attacker.example/collect"), + {}, + base_url="https://api.tangle.test", + token="secret-token", + ) + + +def test_build_operation_request_rejects_network_path_reference() -> None: + with pytest.raises(ValueError, match="must be relative"): + build_operation_request( + _operation("//attacker.example/collect"), + {}, + base_url="https://api.tangle.test", + token="secret-token", + ) + + +def test_build_operation_request_allows_relative_paths() -> None: + method, url, headers, content = build_operation_request( + _operation("/api/components/{id}"), + {}, + base_url="https://api.tangle.test", + token="secret-token", + ) + + assert method == "GET" + assert url == "https://api.tangle.test/api/components/{id}" + assert headers["Authorization"] == "Bearer secret-token" + assert content is None diff --git a/tests/test_args_container.py b/tests/test_args_container.py new file mode 100644 index 0000000..43212fa --- /dev/null +++ b/tests/test_args_container.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json + +import pytest + +from tangle_cli.args_container import ArgsContainer, ConfigFileError + + +def test_load_none_returns_single_empty_config() -> None: + [args] = ArgsContainer.load(None, name=(None, None)) + + assert args.name is None + assert args._config == {} + + +def test_load_yaml_object_and_cli_precedence(tmp_path) -> None: + config = tmp_path / "config.yaml" + config.write_text( + "name: from-config\n" + "limit: 5\n" + "payload:\n" + " enabled: true\n", + encoding="utf-8", + ) + + [args] = ArgsContainer.load( + config, + name=("from-cli", None), + limit=(None, None), + payload=(None, None), + ) + + assert args.name == "from-cli" + assert args.limit == 5 + assert args.payload == {"enabled": True} + + +def test_load_json_list(tmp_path) -> None: + config = tmp_path / "config.json" + config.write_text( + json.dumps([ + {"name": "one"}, + {"name": "two"}, + ]), + encoding="utf-8", + ) + + args = ArgsContainer.load(config, name=(None, None)) + + assert [entry.name for entry in args] == ["one", "two"] + + +def test_load_defaults_configs_shape(tmp_path) -> None: + config = tmp_path / "config.yaml" + config.write_text( + "_defaults:\n" + " base_url: https://api.default\n" + "configs:\n" + " - name: a\n" + " - name: b\n" + " base_url: https://api.override\n", + encoding="utf-8", + ) + + args = ArgsContainer.load(config, name=(None, None), base_url=(None, None)) + + assert [(entry.name, entry.base_url) for entry in args] == [ + ("a", "https://api.default"), + ("b", "https://api.override"), + ] + + +def test_required_field_can_come_from_config(tmp_path) -> None: + config = tmp_path / "config.yaml" + config.write_text("digest: sha256:abc\n", encoding="utf-8") + + [args] = ArgsContainer.load(config, digest=(None,)) + + assert args.digest == "sha256:abc" + + +def test_required_field_missing_raises() -> None: + with pytest.raises(ConfigFileError, match="digest is required"): + ArgsContainer.load(None, digest=(None,)) + + +def test_json_converter_accepts_strings_and_objects(tmp_path) -> None: + config = tmp_path / "config.yaml" + config.write_text("body:\n name: demo\n", encoding="utf-8") + + [from_config] = ArgsContainer.load( + config, + body=("body", None, None, True, False), + ) + [from_cli] = ArgsContainer.load( + None, + body=("body", '{"name":"cli"}', None, True, False), + ) + + assert from_config.body == {"name": "demo"} + assert from_cli.body == {"name": "cli"} + + +def test_invalid_config_shape_raises(tmp_path) -> None: + config = tmp_path / "config.yaml" + config.write_text("- ok\n", encoding="utf-8") + + with pytest.raises(ConfigFileError, match="entry 0 must be an object"): + ArgsContainer.load(config, name=(None, None)) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..6347feb --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from tangle_cli.artifacts import ArtifactManager + + +def _artifact_response(artifact_id: str, uri: str) -> SimpleNamespace: + return SimpleNamespace( + id=artifact_id, + artifact_data=SimpleNamespace( + uri=uri, + total_size=12, + is_dir=False, + hash="abc123", + created_at="2026-01-01T00:00:00Z", + ), + ) + + +class FakeArtifactClient: + def __init__(self) -> None: + self.artifact_responses: dict[str, Any] = {} + self.artifact_errors: dict[str, Exception] = {} + self.execution_details: dict[str, Any] = {} + self.run_details: dict[str, Any] = {} + self.artifacts_get_calls: list[str] = [] + self.get_execution_details_calls: list[str] = [] + self.get_run_details_calls: list[str] = [] + + def artifacts_get(self, artifact_id: str) -> Any: + self.artifacts_get_calls.append(artifact_id) + if artifact_id in self.artifact_errors: + raise self.artifact_errors[artifact_id] + return self.artifact_responses[artifact_id] + + def get_execution_details(self, execution_id: str) -> Any: + self.get_execution_details_calls.append(execution_id) + return self.execution_details[execution_id] + + def get_run_details(self, run_id: str) -> Any: + self.get_run_details_calls.append(run_id) + return self.run_details[run_id] + + +def _task( + *, + name: str = "Component", + digest: str | None = None, + output_artifacts: dict[str, Any] | None = None, + is_graph: bool = False, +) -> SimpleNamespace: + return SimpleNamespace( + name=name, + digest=digest, + execution_output_artifacts=output_artifacts or {}, + is_graph=is_graph, + ) + + +def _execution(graph_tasks: dict[str, Any], child_executions: dict[str, Any] | None = None) -> SimpleNamespace: + return SimpleNamespace( + task_spec=SimpleNamespace(graph_tasks=graph_tasks), + child_executions=child_executions or {}, + ) + + +def test_get_artifacts_resolves_direct_artifact_ids_without_run_tree() -> None: + client = FakeArtifactClient() + client.artifact_responses = { + "artifact-1": _artifact_response("artifact-1", "gs://bucket/artifact-1"), + "artifact-2": _artifact_response("artifact-2", "gs://bucket/artifact-2"), + } + + artifacts = ArtifactManager(client=client).get_artifacts( + "run-1", + {"artifact_ids": ["artifact-1", "artifact-2"]}, + ) + + assert list(artifacts) == ["artifact-1", "artifact-2"] + assert artifacts["artifact-1"].uri == "gs://bucket/artifact-1" + assert client.artifacts_get_calls == ["artifact-1", "artifact-2"] + assert client.get_run_details_calls == [] + assert client.get_execution_details_calls == [] + + +def test_get_artifacts_resolves_execution_output_lookup() -> None: + client = FakeArtifactClient() + client.execution_details["exec-1"] = SimpleNamespace( + output_artifacts={"model": {"id": "artifact-model"}, "metrics": {"id": "artifact-metrics"}} + ) + client.artifact_responses["artifact-model"] = _artifact_response("artifact-model", "gs://bucket/model") + + artifacts = ArtifactManager(client=client).get_artifacts("run-1", {"executions": {"exec-1": ["model"]}}) + + assert list(artifacts) == ["exec-1/model"] + assert artifacts["exec-1/model"].id == "artifact-model" + assert client.get_execution_details_calls == ["exec-1"] + assert client.artifacts_get_calls == ["artifact-model"] + + +def test_get_artifacts_resolves_task_query_from_run_details_tree() -> None: + client = FakeArtifactClient() + client.run_details["run-1"] = SimpleNamespace( + execution=_execution( + { + "Train": _task( + name="Trainer", + output_artifacts={"model": "artifact-model", "metrics": "artifact-metrics"}, + ) + } + ) + ) + client.artifact_responses["artifact-model"] = _artifact_response("artifact-model", "gs://bucket/model") + + artifacts = ArtifactManager(client=client).get_artifacts("run-1", {"tasks": {"Train": ["model"]}}) + + assert list(artifacts) == ["Train/model"] + assert artifacts["Train/model"].uri == "gs://bucket/model" + assert client.get_run_details_calls == ["run-1"] + + +def test_get_artifacts_resolves_component_name_and_digest_queries() -> None: + client = FakeArtifactClient() + client.run_details["run-1"] = SimpleNamespace( + execution=_execution( + { + "Embed": _task(name="Embed Text", digest="sha256:embed", output_artifacts={"vectors": "artifact-vectors"}), + "Score": _task(name="Score", digest="sha256:score", output_artifacts={"scores": "artifact-scores"}), + } + ) + ) + client.artifact_responses["artifact-vectors"] = _artifact_response("artifact-vectors", "gs://bucket/vectors") + client.artifact_responses["artifact-scores"] = _artifact_response("artifact-scores", "gs://bucket/scores") + + artifacts = ArtifactManager(client=client).get_artifacts( + "run-1", + { + "components": [ + {"name": "Embed Text", "outputs": ["vectors"]}, + {"digest": "sha256:score"}, + ] + }, + ) + + assert list(artifacts) == ["Embed/vectors", "Score/scores"] + assert artifacts["Embed/vectors"].id == "artifact-vectors" + assert artifacts["Score/scores"].id == "artifact-scores" + + +def test_get_artifacts_unions_outputs_from_multiple_matching_selectors() -> None: + client = FakeArtifactClient() + client.run_details["run-1"] = SimpleNamespace( + execution=_execution( + { + "Train": _task( + name="Trainer", + digest="sha256:trainer", + output_artifacts={"model": "artifact-model", "metrics": "artifact-metrics"}, + ) + } + ) + ) + client.artifact_responses["artifact-model"] = _artifact_response("artifact-model", "gs://bucket/model") + client.artifact_responses["artifact-metrics"] = _artifact_response("artifact-metrics", "gs://bucket/metrics") + + artifacts = ArtifactManager(client=client).get_artifacts( + "run-1", + { + "tasks": {"Train": ["model"]}, + "components": [{"digest": "sha256:trainer", "outputs": ["metrics"]}], + }, + ) + + assert list(artifacts) == ["Train/model", "Train/metrics"] + assert artifacts["Train/model"].id == "artifact-model" + assert artifacts["Train/metrics"].id == "artifact-metrics" + + +def test_get_artifacts_resolves_nested_subgraph_task_paths() -> None: + client = FakeArtifactClient() + nested_execution = _execution( + {"Inner": _task(name="Inner Component", output_artifacts={"out": "artifact-inner"})} + ) + client.run_details["run-1"] = SimpleNamespace( + execution=_execution( + {"Subgraph": _task(name="Subgraph", is_graph=True)}, + child_executions={"Subgraph": nested_execution}, + ) + ) + client.artifact_responses["artifact-inner"] = _artifact_response("artifact-inner", "gs://bucket/inner") + + artifacts = ArtifactManager(client=client).get_artifacts("run-1", {"tasks": {"Subgraph/Inner": ["out"]}}) + + assert list(artifacts) == ["Subgraph/Inner/out"] + assert artifacts["Subgraph/Inner/out"].uri == "gs://bucket/inner" + + +def test_get_artifacts_serializes_per_artifact_lookup_errors() -> None: + client = FakeArtifactClient() + client.artifact_responses["good"] = _artifact_response("good", "gs://bucket/good") + client.artifact_errors["bad"] = RuntimeError("not found") + + artifacts = ArtifactManager(client=client).get_artifacts("run-1", {"artifact_ids": ["good", "bad"]}) + + assert artifacts["good"].uri == "gs://bucket/good" + assert artifacts["bad"].id == "bad" + assert artifacts["bad"].uri == "" + assert artifacts["bad"].error == "not found" + + serialized = ArtifactManager.serialize_artifacts(artifacts) + assert {entry["key"]: entry for entry in serialized}["bad"] == { + "id": "bad", + "uri": "", + "key": "bad", + "total_size": 0, + "is_dir": False, + "error": "not found", + } + + +def test_get_artifacts_requires_execution_details_for_task_queries() -> None: + client = FakeArtifactClient() + client.run_details["run-1"] = SimpleNamespace(execution=None) + + with pytest.raises(RuntimeError, match="No execution details"): + ArtifactManager(client=client).get_artifacts("run-1", {"tasks": {"Train": []}}) diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py new file mode 100644 index 0000000..c7779d8 --- /dev/null +++ b/tests/test_artifacts_cli.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import builtins +import importlib +import json +import sys +from typing import Any + +from tangle_cli import artifacts as artifacts_module +from tangle_cli import artifacts_cli, cli + + +def run_app(app: Any, args: list[str]) -> None: + try: + app(args) + except SystemExit as exc: + if exc.code not in (0, None): + raise + + +def test_sdk_artifacts_help_lists_get_only(capsys) -> None: + app = cli.build_app() + + run_app(app, ["sdk", "artifacts", "--help"]) + + output = capsys.readouterr().out + assert "get" in output + assert "download" not in output + assert "upload" not in output + assert "signed" not in output.lower() + + +def test_sdk_artifacts_get_cli_config_auth_and_env_isolation(monkeypatch, tmp_path, capsys) -> None: + config = tmp_path / "artifacts.yaml" + config.write_text( + "run_id: run-config\n" + "query: '{\"artifact_ids\": [\"artifact-config\"]}'\n" + "base_url: https://config.example\n" + "token: config-token\n" + "auth_header: Bearer config-auth\n" + "header:\n" + " - 'X-Config: yes'\n", + encoding="utf-8", + ) + fake_client = object() + client_calls: list[dict[str, Any]] = [] + get_calls: list[dict[str, Any]] = [] + + def fake_client_from_options(**kwargs: Any) -> object: + client_calls.append(kwargs) + return fake_client + + def fake_get_artifacts(self, run_id: str, query: dict[str, Any]) -> dict[str, object]: + get_calls.append({"run_id": run_id, "query": query, "client": self._require_client()}) + return {"artifact-config": object()} + + monkeypatch.setattr(artifacts_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(artifacts_module.ArtifactManager, "get_artifacts", fake_get_artifacts) + monkeypatch.setattr( + artifacts_module.ArtifactManager, + "serialize_artifacts", + staticmethod(lambda artifacts: [{"id": "artifact-config", "uri": "gs://bucket/config", "key": "artifact-config"}]), + ) + + app = cli.build_app() + run_app(app, ["sdk", "artifacts", "get", "--config", str(config)]) + + result = json.loads(capsys.readouterr().out) + assert result == { + "status": "success", + "run_id": "run-config", + "count": 1, + "artifacts": [{"id": "artifact-config", "key": "artifact-config", "uri": "gs://bucket/config"}], + } + assert client_calls == [ + { + "base_url": "https://config.example", + "token": "config-token", + "auth_header": "Bearer config-auth", + "header": ["X-Config: yes"], + "include_env_credentials": False, + "command_name": "artifact commands", + } + ] + assert get_calls == [ + { + "run_id": "run-config", + "query": {"artifact_ids": ["artifact-config"]}, + "client": fake_client, + } + ] + + +def test_sdk_artifacts_get_cli_base_url_keeps_env_credentials(monkeypatch, tmp_path, capsys) -> None: + config = tmp_path / "artifacts.yaml" + config.write_text( + "run_id: run-config\n" + "query:\n" + " artifact_ids: [artifact-config]\n" + "base_url: https://config.example\n", + encoding="utf-8", + ) + client_calls: list[dict[str, Any]] = [] + + def fake_client_from_options(**kwargs: Any) -> object: + client_calls.append(kwargs) + return object() + + monkeypatch.setattr(artifacts_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(artifacts_module.ArtifactManager, "get_artifacts", lambda self, *args, **kwargs: {}) + monkeypatch.setattr(artifacts_module.ArtifactManager, "serialize_artifacts", staticmethod(lambda artifacts: [])) + + app = cli.build_app() + run_app( + app, + [ + "sdk", + "artifacts", + "get", + "--config", + str(config), + "--base-url", + "https://cli.example", + ], + ) + + json.loads(capsys.readouterr().out) + assert client_calls[-1]["base_url"] == "https://cli.example" + assert client_calls[-1]["include_env_credentials"] is True + + +def test_sdk_artifacts_get_missing_native_api_uses_friendly_error(monkeypatch, tmp_path) -> None: + config = tmp_path / "artifacts.yaml" + config.write_text( + "run_id: run-config\nquery: '{\"artifact_ids\": [\"artifact-config\"]}'\n", + encoding="utf-8", + ) + import tangle_cli + + for attr in ("artifacts", "client", "models"): + if hasattr(tangle_cli, attr): + monkeypatch.delattr(tangle_cli, attr) + for name in list(sys.modules): + if name in {"tangle_cli.artifacts", "tangle_cli.client", "tangle_cli.models"} or name.startswith("tangle_api"): + monkeypatch.delitem(sys.modules, name, raising=False) + + original_import = builtins.__import__ + + def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "tangle_api" or name.startswith("tangle_api."): + raise ModuleNotFoundError("No module named 'tangle_api'", name="tangle_api") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + app = cli.build_app() + + try: + app(["sdk", "artifacts", "get", "--config", str(config)]) + except SystemExit as exc: + message = str(exc) + 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 + + +def test_sdk_artifacts_get_cli_requires_query(tmp_path) -> None: + config = tmp_path / "artifacts.yaml" + config.write_text("run_id: run-config\n", encoding="utf-8") + app = cli.build_app() + + try: + app(["sdk", "artifacts", "get", "--config", str(config)]) + except SystemExit as exc: + assert exc.code not in (0, None) + else: # pragma: no cover - defensive assertion + raise AssertionError("expected missing query to fail") + + +def test_artifacts_cli_imports_without_native_api(monkeypatch) -> None: + for name in list(sys.modules): + if name == "tangle_cli.artifacts_cli" or name.startswith("tangle_api"): + del sys.modules[name] + + original_import = builtins.__import__ + + def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "tangle_api" or name.startswith("tangle_api."): + raise AssertionError(f"unexpected native API import: {name}") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + module = importlib.import_module("tangle_cli.artifacts_cli") + + assert module.app.name == ("artifacts",) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..5456ac0 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import tangle_cli.client as client_module +from tangle_cli.client import TangleApiClient +from tangle_cli.models import ComponentInfo + + +def test_find_existing_components_matches_exact_names_case_insensitively() -> None: + client = TangleApiClient("https://api.test") + client.list_published_component_infos = MagicMock( + return_value=[ + ComponentInfo(name="Scrape V2", digest="matching-digest"), + ComponentInfo(name="Other", digest="other-digest"), + ] + ) + + results = client.find_existing_components(names=["scrape v2"]) + + assert [component.digest for component in results] == ["matching-digest"] + + +def test_get_run_pipeline_spec_fetches_raw_root_execution_without_enrichment() -> None: + client = TangleApiClient("https://api.test") + task_spec = MagicMock(name="task_spec") + execution = SimpleNamespace(task_spec=task_spec) + client.pipeline_runs_get = MagicMock( + return_value={"id": "run-1", "root_execution_id": "root-exec-1"} + ) + client.executions_details = MagicMock(return_value=execution) + client.get_run_details = MagicMock( + side_effect=AssertionError("get_run_pipeline_spec must not enrich via get_run_details") + ) + client._enrich_execution_tree = MagicMock() + + assert client.get_run_pipeline_spec("run-1") is task_spec + client.executions_details.assert_called_once_with("root-exec-1") + client.get_run_details.assert_not_called() + client._enrich_execution_tree.assert_not_called() + + +def test_get_run_pipeline_spec_reads_generated_run_response_directly(monkeypatch) -> None: + def fail_from_dict(*args, **kwargs): + raise AssertionError("get_run_pipeline_spec should not round-trip through PipelineRun.from_dict") + + monkeypatch.setattr(client_module.PipelineRun, "from_dict", fail_from_dict) + client = TangleApiClient("https://api.test") + task_spec = MagicMock(name="task_spec") + client.pipeline_runs_get = MagicMock(return_value=SimpleNamespace(root_execution_id="root-exec-1")) + client.executions_details = MagicMock(return_value=SimpleNamespace(task_spec=task_spec)) + + assert client.get_run_pipeline_spec("run-1") is task_spec + client.executions_details.assert_called_once_with("root-exec-1") diff --git a/tests/test_codegen.py b/tests/test_codegen.py new file mode 100644 index 0000000..9a3b3a0 --- /dev/null +++ b/tests/test_codegen.py @@ -0,0 +1,1218 @@ +from __future__ import annotations + +import importlib +import json +from pathlib import Path + +import pytest + +from tangle_cli.openapi import codegen, parser + + +def _schema(paths: dict | None = None) -> dict: + return {"openapi": "3.1.0", "paths": paths or {"/services/ping": {"get": {}}}} + + +def _generated_files(tmp_path: Path) -> list[Path]: + return [ + tmp_path / "generated" / "__init__.py", + tmp_path / "generated" / "models.py", + tmp_path / "generated" / "operations.py", + ] + + +def test_generate_operations_rejects_absolute_url_paths() -> None: + with pytest.raises(ValueError, match="must be relative"): + codegen.generate_operations(_schema({"https://attacker.example/collect": {"get": {}}})) + + +def test_generate_operations_rejects_network_path_references() -> None: + with pytest.raises(ValueError, match="must be relative"): + codegen.generate_operations(_schema({"//attacker.example/collect": {"get": {}}})) + + +def test_codegen_module_imports_with_default_openapi_resource_package() -> None: + imported = importlib.import_module("tangle_cli.openapi.codegen") + + assert imported is codegen + assert parser.DEFAULT_OPENAPI_RESOURCE_PACKAGE == "tangle_api.schema" + + +def test_default_openapi_snapshot_lives_in_api_package() -> None: + assert parser.DEFAULT_OPENAPI_PATH.match( + "*/packages/tangle-api/src/tangle_api/schema/openapi.json" + ) + schema = parser.load_openapi_schema() + assert "paths" in schema + + +def test_explicit_openapi_path_does_not_require_default_snapshot(tmp_path) -> None: + openapi = tmp_path / "custom-openapi.json" + openapi.write_text(json.dumps(_schema()), encoding="utf-8") + + schema = parser.load_openapi_schema(openapi) + + assert schema["paths"] == {"/services/ping": {"get": {}}} + + +def test_codegen_update_from_openapi_url_writes_snapshot(tmp_path) -> None: + source = tmp_path / "official-openapi.json" + destination = tmp_path / "openapi.json" + source.write_text(json.dumps(_schema()), encoding="utf-8") + + written = codegen.update_openapi_from_url( + source.as_uri(), + destination=destination, + ) + + assert written == destination + assert json.loads(destination.read_text(encoding="utf-8"))["paths"] == { + "/services/ping": {"get": {}} + } + + +def test_update_openapi_from_backend_imports_app_and_uses_temp_database(tmp_path) -> None: + backend = tmp_path / "backend" + destination = tmp_path / "openapi.json" + backend.mkdir() + (backend / "api_server_main.py").write_text( + """ +import os + +class App: + def openapi(self): + return { + "openapi": "3.1.0", + "x-database-uri": os.environ.get("DATABASE_URI"), + "paths": {"/api/components/{digest}": {"get": {}}}, + } + +app = App() +""".strip(), + encoding="utf-8", + ) + + written = codegen.update_openapi_from_backend( + backend_path=backend, + destination=destination, + ) + + schema = json.loads(written.read_text(encoding="utf-8")) + assert schema["paths"] == {"/api/components/{digest}": {"get": {}}} + assert schema["x-database-uri"].startswith("sqlite:///") + assert "openapi_codegen.sqlite" in schema["x-database-uri"] + + +def test_codegen_main_no_args_uses_default_backend_and_prints_summary( + monkeypatch, tmp_path, capsys +) -> None: + backend = tmp_path / "third_party" / "tangle" + default_snapshot = tmp_path / "packages" / "tangle-api" / "src" / "tangle_api" / "schema" / "openapi.json" + backend.mkdir(parents=True) + default_snapshot.parent.mkdir(parents=True) + (backend / "api_server_main.py").write_text("app = object()\n", encoding="utf-8") + calls: list[tuple[str, object]] = [] + + def fake_update_openapi_from_backend(**kwargs): + calls.append(("update", kwargs)) + openapi_path = Path(kwargs["destination"]) + openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") + return openapi_path + + 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, "DEFAULT_BACKEND_PATH", backend) + monkeypatch.setattr(codegen, "DEFAULT_OPENAPI_PATH", default_snapshot) + monkeypatch.setattr(codegen, "update_openapi_from_backend", fake_update_openapi_from_backend) + monkeypatch.setattr(codegen, "generate", fake_generate) + + codegen.main([ + "--out", + str(tmp_path / "generated"), + ]) + + assert calls[0][0] == "update" + assert calls[0][1]["backend_path"] == backend + assert calls[0][1]["destination"] == default_snapshot + 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 + assert f"Wrote {default_snapshot}" in output + assert f"Wrote {tmp_path / 'generated' / 'models.py'}" in output + assert "Generated 1 operations from 1 paths" in output + + +def test_codegen_main_missing_default_backend_fails_with_guidance( + monkeypatch, tmp_path, capsys +) -> None: + monkeypatch.setattr(codegen, "DEFAULT_BACKEND_PATH", tmp_path / "missing" / "tangle") + + with pytest.raises(SystemExit) as exc_info: + codegen.main(["--openapi", str(tmp_path / "openapi.json")]) + + assert exc_info.value.code == 1 + assert ( + "Default backend submodule not found. Run: git submodule update --init --recursive" + in capsys.readouterr().err + ) + + +def test_codegen_main_from_snapshot_is_explicit(monkeypatch, tmp_path, capsys) -> None: + calls: list[tuple[str, object]] = [] + + def fail_update(*args, **kwargs): # pragma: no cover - assertion helper + raise AssertionError("snapshot mode must not update openapi.json") + + 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, "update_openapi_from_backend", fail_update) + monkeypatch.setattr(codegen, "update_openapi_from_url", fail_update) + monkeypatch.setattr(codegen, "generate", fake_generate) + + codegen.main([ + "--openapi", + str(tmp_path / "openapi.json"), + "--out", + str(tmp_path / "generated"), + "--from-snapshot", + ]) + + 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 + assert f"Wrote {tmp_path / 'openapi.json'}" not in output + assert "Generated 1 operations from 1 paths" in output + + +def test_codegen_main_from_default_snapshot_uses_bundled_resolution(monkeypatch, tmp_path, capsys) -> None: + calls: list[tuple[str, object]] = [] + default_snapshot = tmp_path / "packages" / "tangle-api" / "src" / "tangle_api" / "schema" / "openapi.json" + default_snapshot.parent.mkdir(parents=True) + default_snapshot.write_text(json.dumps(_schema()), encoding="utf-8") + + 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, "DEFAULT_OPENAPI_PATH", default_snapshot) + monkeypatch.setattr(codegen, "generate", fake_generate) + + codegen.main(["--from-snapshot", "--out", str(tmp_path / "generated")]) + + assert calls[0][0] == "generate" + assert calls[0][1]["openapi_path"] is None + output = capsys.readouterr().out + assert f"Loaded OpenAPI from snapshot: {default_snapshot}" in output + assert "Generated 1 operations from 1 paths" in output + + +def test_codegen_main_accepts_custom_operations_class_name(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"), + "--out", + str(tmp_path / "generated"), + "--from-snapshot", + "--operations-class-name", + "GeneratedTangleApiExtensions", + ]) + + 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 + + +def test_codegen_main_openapi_url_writes_default_snapshot_before_generating( + monkeypatch, tmp_path, capsys +) -> None: + calls: list[tuple[str, object]] = [] + default_snapshot = tmp_path / "packages" / "tangle-api" / "src" / "tangle_api" / "schema" / "openapi.json" + + def fake_update_openapi_from_url(openapi_url, **kwargs): + calls.append(("update-url", {"openapi_url": openapi_url, **kwargs})) + openapi_path = Path(kwargs["destination"]) + openapi_path.parent.mkdir(parents=True) + openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") + return openapi_path + + 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, "DEFAULT_OPENAPI_PATH", default_snapshot) + monkeypatch.setattr(codegen, "update_openapi_from_url", fake_update_openapi_from_url) + monkeypatch.setattr(codegen, "generate", fake_generate) + + codegen.main([ + "--out", + str(tmp_path / "generated"), + "--openapi-url", + "https://example.com/openapi.json", + ]) + + assert calls[0] == ( + "update-url", + { + "openapi_url": "https://example.com/openapi.json", + "destination": default_snapshot, + }, + ) + 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 + assert f"Wrote {default_snapshot}" in output + assert "Generated 1 operations from 1 paths" in output + + +def test_codegen_main_openapi_url_respects_explicit_openapi_destination( + monkeypatch, tmp_path +) -> None: + calls: list[tuple[str, object]] = [] + explicit_snapshot = tmp_path / "custom-openapi.json" + + def fake_update_openapi_from_url(openapi_url, **kwargs): + calls.append(("update-url", {"openapi_url": openapi_url, **kwargs})) + openapi_path = Path(kwargs["destination"]) + openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") + return openapi_path + + 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, "update_openapi_from_url", fake_update_openapi_from_url) + monkeypatch.setattr(codegen, "generate", fake_generate) + + codegen.main([ + "--openapi", + str(explicit_snapshot), + "--out", + str(tmp_path / "generated"), + "--openapi-url", + "https://example.com/openapi.json", + ]) + + assert calls[0] == ( + "update-url", + { + "openapi_url": "https://example.com/openapi.json", + "destination": str(explicit_snapshot), + }, + ) + assert calls[1][0] == "generate" + assert calls[1][1]["openapi_path"] == str(explicit_snapshot) + + +def test_generate_writes_support_modules_to_custom_out(tmp_path) -> None: + openapi = tmp_path / "openapi.json" + out = tmp_path / "custom_generated_api" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": { + "/api/published_components/": { + "get": { + "tags": ["components"], + "summary": "List published components", + "parameters": [ + { + "name": "name_substring", + "in": "query", + "schema": {"type": "string"}, + } + ], + } + } + }, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate(openapi, out) + + assert (out / "__init__.py").exists() + assert (out / "models.py").exists() + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "class GeneratedTangleApiOperations" in operations + assert "def published_components_list" in operations + assert "name_substring" in operations + + +def test_generate_models_adds_default_component_spec_alias() -> None: + schema = { + "openapi": "3.1.0", + "paths": { + "/api/components/{digest}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ComponentSpecOutput"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ComponentSpecOutput": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "title": "ComponentSpecOutput", + } + } + }, + } + + models = codegen.generate_models(schema, model_extension_module="") + operations = codegen.generate_operations(schema) + + assert "class _ComponentSpecGenerated(TangleGeneratedModel):" in models + assert "class ComponentSpec(_ComponentSpecGenerated):" in models + assert "class _ComponentSpecOutputGenerated(TangleGeneratedModel):" 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 + + +def test_component_spec_alias_operation_deserializes_raw_spec(monkeypatch, tmp_path) -> None: + schema = { + "openapi": "3.1.0", + "paths": { + "/api/components/{digest}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ComponentSpecOutput"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ComponentSpecOutput": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "metadata": {"type": "object"}, + }, + } + } + }, + } + openapi = tmp_path / "openapi.json" + openapi.write_text(json.dumps(schema), encoding="utf-8") + out = tmp_path / "aliased_component_api" + codegen.generate(openapi, out) + monkeypatch.syspath_prepend(str(tmp_path)) + generated_operations = importlib.import_module("aliased_component_api.operations") + + class Client(generated_operations.GeneratedTangleApiOperations): + def _request_json(self, *args, response_model=None, **kwargs): + return response_model.from_dict({ + "name": "Widget", + "metadata": {"annotations": {"version": "1"}}, + }) + + spec = Client().components_get("sha256:abc") + + assert spec.__class__.__name__ == "ComponentSpec" + assert spec.name == "Widget" + assert spec.version == "1" + assert spec.data["name"] == "Widget" + + +def test_generate_models_can_disable_default_model_aliases() -> None: + schema = { + "openapi": "3.1.0", + "paths": {}, + "components": { + "schemas": { + "ComponentSpecOutput": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + } + }, + } + + models = codegen.generate_models(schema, model_extension_module="", model_aliases="") + + assert "class _ComponentSpecGenerated" not in models + assert "class _ComponentSpecOutputGenerated(TangleGeneratedModel):" in models + + +def test_generate_models_supports_custom_model_aliases() -> None: + schema = { + "openapi": "3.1.0", + "paths": { + "/api/widgets/{id}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/WidgetOutput"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "WidgetOutput": { + "type": "object", + "properties": {"id": {"type": "string"}}, + } + } + }, + } + + models = codegen.generate_models(schema, model_extension_module="", 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 "from .models import Widget" in operations + assert "-> Widget:" in operations + assert "response_model=Widget" in operations + + +def test_generate_models_uses_builtin_model_extension_module_by_default() -> None: + models = codegen.generate_models({ + "openapi": "3.1.0", + "paths": {}, + "components": { + "schemas": { + "GetGraphExecutionStateResponse": { + "type": "object", + "properties": { + "child_execution_status_stats": {"type": "object"}, + }, + } + } + }, + }) + + 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 "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" + + +def test_generate_operations_request_body_schema_override_preserves_raw_body(monkeypatch, tmp_path) -> None: + openapi = tmp_path / "openapi.json" + out = tmp_path / "raw_body_api" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": { + "/api/search": { + "post": { + "operationId": "search_components", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + } + } + } + }, + "responses": {"200": {"content": {"application/json": {"schema": {"type": "object"}}}}}, + } + } + }, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate( + openapi, + out, + request_body_schemas={ + "search_create": { + "type": "object", + "additionalProperties": True, + "title": "SearchQuery", + } + }, + ) + + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "def search_create(self, body: dict[str, Any] | None = None)" in operations + assert "query:" not in operations + assert "json_data=body" in operations + + monkeypatch.syspath_prepend(str(tmp_path)) + generated_operations = importlib.import_module("raw_body_api.operations") + + class Client(generated_operations.GeneratedTangleApiOperations): + def __init__(self) -> None: + self.calls = [] + + def _request_json(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return {"ok": True} + + payload = {"predicate": {"nested": {"value": True}}, "page_token": "next"} + client = Client() + client.search_create(body=payload) + + assert client.calls[0][1]["json_data"] is payload + + +def test_generate_operations_without_request_body_override_omits_unset_optional_body_kwargs(monkeypatch, tmp_path) -> None: + openapi = tmp_path / "openapi.json" + out = tmp_path / "normal_body_api" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": { + "/api/search": { + "post": { + "operationId": "search_components", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + } + } + } + }, + } + } + }, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate(openapi, out) + + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "def search_create(self," in operations + assert "query: Any = None" in operations + assert "limit: Any = None" in operations + assert "json_data={key: value for key, value in {'limit': limit, 'query': query}.items() if value is not None}" in operations + assert "body: dict[str, Any] | None" not in operations + + monkeypatch.syspath_prepend(str(tmp_path)) + generated_operations = importlib.import_module("normal_body_api.operations") + + class Client(generated_operations.GeneratedTangleApiOperations): + def __init__(self) -> None: + self.calls = [] + + def _request_json(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return {"ok": True} + + client = Client() + client.search_create(query="widgets") + client.search_create() + + assert client.calls[0][1]["json_data"] == {"query": "widgets"} + assert client.calls[1][1]["json_data"] == {} + + +def test_generate_operations_preserves_required_body_kwargs(monkeypatch, tmp_path) -> None: + openapi = tmp_path / "openapi.json" + out = tmp_path / "required_body_api" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": { + "/api/secrets": { + "post": { + "operationId": "create_secret", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["secret_value"], + "properties": { + "secret_value": {"type": "string"}, + "description": {"type": "string"}, + }, + } + } + } + }, + } + } + }, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate(openapi, out) + + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "def secrets_create(self, secret_value: Any, description: Any = None)" in operations + assert "json_data={**{'secret_value': secret_value}, **{key: value for key, value in {'description': description}.items() if value is not None}}" in operations + + monkeypatch.syspath_prepend(str(tmp_path)) + generated_operations = importlib.import_module("required_body_api.operations") + + class Client(generated_operations.GeneratedTangleApiOperations): + def __init__(self) -> None: + self.calls = [] + + def _request_json(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return {"ok": True} + + client = Client() + client.secrets_create("secret") + + assert client.calls[0][1]["json_data"] == {"secret_value": "secret"} + + +def test_codegen_main_accepts_request_body_schema_file(monkeypatch, tmp_path) -> None: + calls: list[tuple[str, object]] = [] + schema_file = tmp_path / "body-schema.json" + schema_file.write_text( + json.dumps({"type": "object", "additionalProperties": True, "title": "Body"}), + encoding="utf-8", + ) + + 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", + "--request-body-schema", + 'inline_op={"type":"object","additionalProperties":true}', + "--request-body-schema-file", + f"file_op={schema_file}", + ]) + + assert calls[0][1]["request_body_schemas"] == { + "inline_op": {"type": "object", "additionalProperties": True}, + "file_op": {"type": "object", "additionalProperties": True, "title": "Body"}, + } + + +def test_codegen_main_rejects_invalid_request_body_schema(tmp_path, capsys) -> None: + with pytest.raises(SystemExit) as exc_info: + codegen.main([ + "--openapi", + str(tmp_path / "openapi.json"), + "--from-snapshot", + "--request-body-schema", + "search_components=not-json", + ]) + + assert exc_info.value.code == 2 + 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" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": {"/api/components/{digest}": {"get": {}}}, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate( + openapi, + out, + operations_class_name="GeneratedTangleApiExtensions", + ) + + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "class GeneratedTangleApiExtensions" in operations + assert "if TYPE_CHECKING:" in operations + assert "def _request_json(" in operations + assert "__all__ = ['GeneratedTangleApiExtensions']" in operations + + +def test_codegen_main_rejects_invalid_operations_class_name(tmp_path, capsys) -> None: + with pytest.raises(SystemExit) as exc_info: + codegen.main([ + "--openapi", + str(tmp_path / "openapi.json"), + "--operations-class-name", + "not-valid!", + ]) + + assert exc_info.value.code == 2 + assert "Invalid generated operations class name" in capsys.readouterr().err + + +def test_generate_operations_uses_concrete_return_annotations() -> None: + operations = codegen.generate_operations({ + "openapi": "3.1.0", + "paths": { + "/api/arrays": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/FooResponse"}, + } + } + } + } + } + } + }, + "/api/maps": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {"type": "string"}, + } + } + } + } + } + } + }, + "/api/nullable": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/FooResponse"}, + {"type": "null"}, + ] + } + } + } + } + } + } + }, + "/api/status": { + "get": { + "responses": { + "200": { + "content": { + "application/json": {"schema": {"type": "string"}} + } + } + } + } + }, + "/api/things/{id}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/FooResponse"} + } + } + } + } + }, + "delete": {"responses": {"204": {"description": "deleted"}}}, + }, + "/api/unknown": {"get": {}}, + }, + "components": { + "schemas": { + "FooResponse": { + "type": "object", + "properties": {"id": {"type": "string"}}, + } + } + }, + }) + + assert "from collections.abc import Mapping" in operations + assert "from typing import TYPE_CHECKING, Any" in operations + assert "class GeneratedTangleApiOperations" in operations + assert "if TYPE_CHECKING:" in operations + assert "path_params: Mapping[str, Any] | None = None" in operations + assert "def _request_json(" in operations + assert "__all__ = ['GeneratedTangleApiOperations']" in operations + assert "from .models import FooResponse" in operations + assert "def arrays_list(self) -> list[FooResponse]:" in operations + assert "def maps_list(self) -> dict[str, Any]:" in operations + 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 "def things_delete(self, id: Any) -> None:" in operations + assert "def unknown_list(self) -> Any:" in operations diff --git a/tests/test_component_from_func.py b/tests/test_component_from_func.py new file mode 100644 index 0000000..a4141fe --- /dev/null +++ b/tests/test_component_from_func.py @@ -0,0 +1,2565 @@ +"""Tests for the native component YAML generator (component_from_func).""" + +import inspect +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest +import yaml + +from tangle_cli.component_from_func import ( + AuthoringStripError, + FunctionSpec, + InputPath, + OutputPath, + ParamInfo, + _build_argparse_code, + _build_args_section, + _build_pip_install_command, + _build_python_source, + _python_name_to_component_name, + _resolve_annotation, + _resolve_return_type, + _serialize_default, + _strip_authoring_constructs, + _strip_main_guard, + _strip_type_hints, + build_component_dict, + extract_interface, + generate_component_yaml, + get_function_from_module, + load_python_module, + read_dependencies, +) +from tangle_cli.module_bundler import ModuleBundler + +# Real on-disk python-pipeline fixtures (``task_env_strip_*`` for Phase 3). +_PIPELINE_FIXTURES = Path(__file__).parent / "fixtures" / "python_pipeline" + +# ============================================================================ +# Type resolution tests +# ============================================================================ + + +class TestTypeResolution: + def test_str_type(self): + tangle, deser, kind = _resolve_annotation(str) + assert tangle == "String" + assert deser == "str" + assert kind == "input" + + def test_int_type(self): + tangle, deser, kind = _resolve_annotation(int) + assert tangle == "Integer" + assert deser == "int" + assert kind == "input" + + def test_float_type(self): + tangle, deser, kind = _resolve_annotation(float) + assert tangle == "Float" + assert deser == "float" + assert kind == "input" + + def test_bool_type(self): + tangle, deser, kind = _resolve_annotation(bool) + assert tangle == "Boolean" + assert deser == "_deserialize_bool" + assert kind == "input" + + def test_list_type(self): + tangle, deser, kind = _resolve_annotation(list) + assert tangle == "JsonArray" + assert deser == "json.loads" + assert kind == "input" + + def test_dict_type(self): + tangle, deser, kind = _resolve_annotation(dict) + assert tangle == "JsonObject" + assert deser == "json.loads" + assert kind == "input" + + def test_no_annotation(self): + tangle, deser, kind = _resolve_annotation(inspect.Parameter.empty) + assert tangle == "String" + assert deser == "str" + assert kind == "input" + + def test_output_path(self): + tangle, deser, kind = _resolve_annotation(OutputPath("Text")) + assert tangle == "Text" + assert deser == "_make_parent_dirs_and_return_path" + assert kind == "output" + + def test_input_path(self): + tangle, deser, kind = _resolve_annotation(InputPath("CSV")) + assert tangle == "CSV" + assert deser == "str" + assert kind == "input_path" + + def test_optional_str(self): + from typing import Optional + + tangle, deser, kind = _resolve_annotation(Optional[str]) + assert tangle == "String" + assert kind == "input" + + def test_list_subscript(self): + tangle, deser, kind = _resolve_annotation(list[str]) + assert tangle == "JsonArray" + assert deser == "json.loads" + + def test_dict_subscript(self): + from typing import Any + + tangle, deser, kind = _resolve_annotation(dict[str, Any]) + assert tangle == "JsonObject" + assert deser == "json.loads" + + +# ============================================================================ +# Name conversion tests +# ============================================================================ + + +class TestNameConversion: + def test_simple_name(self): + assert _python_name_to_component_name("my_function") == "My function" + + def test_multi_word(self): + assert _python_name_to_component_name("split_dataset_by_hash") == "Split dataset by hash" + + def test_single_word(self): + assert _python_name_to_component_name("process") == "Process" + + +# ============================================================================ +# Interface extraction tests +# ============================================================================ + + +class TestExtractInterface: + def test_basic_function(self): + def my_func(name: str, count: int = 5) -> str: + """Do something useful.""" + return f"{name}: {count}" + + spec = extract_interface(my_func, {}) + assert spec.name == "my_func" + assert spec.component_name == "My func" + assert spec.description == "Do something useful." + assert len(spec.inputs) == 2 + assert len(spec.outputs) == 0 + + name_param = spec.inputs[0] + assert name_param.yaml_name == "name" + assert name_param.tangle_type == "String" + assert name_param.optional is False + + count_param = spec.inputs[1] + assert count_param.yaml_name == "count" + assert count_param.tangle_type == "Integer" + assert count_param.optional is True + assert count_param.default == 5 + + def test_output_path_stripping(self, tmp_path): + py_file = tmp_path / "my_func.py" + py_file.write_text(textwrap.dedent("""\ + from cloud_pipelines import components + + def my_func( + input_data_path: components.InputPath("CSV"), + output_result_path: components.OutputPath("Text"), + ): + \"\"\"Process data.\"\"\" + pass + """)) + + module = load_python_module(py_file) + func = get_function_from_module(module, "my_func") + spec = extract_interface(func, {}) + assert len(spec.inputs) == 1 + assert spec.inputs[0].yaml_name == "input_data" # _path stripped + assert spec.inputs[0].kind == "input_path" + + assert len(spec.outputs) == 1 + assert spec.outputs[0].yaml_name == "output_result" # _path stripped + assert spec.outputs[0].kind == "output" + + def test_docstring_param_descriptions(self): + def my_func(name: str, value: float): + """Do things. + + Args: + name: The name to use. + value: The numeric value. + """ + pass + + spec = extract_interface(my_func, {}) + assert spec.inputs[0].description == "The name to use." + assert spec.inputs[1].description == "The numeric value." + + def test_bool_and_dict_types(self): + def my_func(flag: bool = False, config: dict | None = None): + """Test function.""" + pass + + spec = extract_interface(my_func, {}) + assert spec.inputs[0].tangle_type == "Boolean" + assert spec.inputs[0].deserializer == "_deserialize_bool" + # dict | None resolves to JsonObject via Optional handling + assert spec.inputs[1].tangle_type == "JsonObject" + assert spec.inputs[1].deserializer == "json.loads" + assert spec.inputs[1].optional is True + + +# ============================================================================ +# Type hint stripping tests +# ============================================================================ + + +class TestStripTypeHints: + def test_basic_stripping(self): + source = "def my_func(name: str, count: int = 5) -> str:\n return f'{name}: {count}'\n" + stripped = _strip_type_hints(source) + assert "name," in stripped + assert "count=5" in stripped or "count = 5" in stripped + assert ": str" not in stripped + assert ": int" not in stripped + assert "-> str" not in stripped + assert "def my_func" in stripped + + def test_components_input_output_path(self): + """Regression test: components.InputPath/OutputPath must be stripped (Python 3.13 bug).""" + source = textwrap.dedent("""\ + def embed_texts( + input_dataset_path: components.InputPath("ApacheParquet"), + output_dataset_path: components.OutputPath("ApacheParquet"), + model_name: str = "all-MiniLM-L6-v2", + ): + pass + """) + stripped = _strip_type_hints(source) + assert "components.InputPath" not in stripped + assert "components.OutputPath" not in stripped + assert "input_dataset_path," in stripped + assert "output_dataset_path," in stripped + assert 'model_name="all-MiniLM-L6-v2"' in stripped or "model_name = " in stripped + + def test_no_annotations(self): + source = "def my_func(name, count=5):\n return name\n" + stripped = _strip_type_hints(source) + assert stripped == source + + def test_return_annotation_only(self): + source = "def my_func(name) -> dict:\n return {}\n" + stripped = _strip_type_hints(source) + assert "-> dict" not in stripped + assert "def my_func(name)" in stripped + + def test_mixed_annotated_and_plain(self): + source = "def my_func(a: int, b, c: str = 'x'):\n pass\n" + stripped = _strip_type_hints(source) + assert ": int" not in stripped + assert ": str" not in stripped + assert "a," in stripped + assert "b," in stripped + + def test_complex_annotation(self): + source = "def my_func(data: dict[str, list[int]], flag: bool = True) -> list[str]:\n pass\n" + stripped = _strip_type_hints(source) + assert "dict[str, list[int]]" not in stripped + assert "-> list[str]" not in stripped + assert "flag=" in stripped or "flag =" in stripped + + def test_multiple_functions(self): + source = textwrap.dedent("""\ + def func_a(x: int) -> str: + pass + + def func_b(y: float = 1.0) -> None: + pass + """) + stripped = _strip_type_hints(source) + assert ": int" not in stripped + assert ": float" not in stripped + assert "-> str" not in stripped + assert "-> None" not in stripped + assert "def func_a(x)" in stripped + assert "def func_b(y" in stripped + + def test_multiline_return_annotation(self): + """Return annotation where -> is on a different line than the type.""" + source = textwrap.dedent("""\ + def my_func(x, y)\\ + -> dict[str, list[int]]: + pass + """) + stripped = _strip_type_hints(source) + assert "->" not in stripped + assert "dict[str, list[int]]" not in stripped + assert "def my_func(x, y)" in stripped + + def test_arrow_search_does_not_cross_functions(self): + """Backward scan for -> must not match a previous function's arrow.""" + source = textwrap.dedent("""\ + def first() -> str: + return "hi" + + def second(): + return 42 + """) + stripped = _strip_type_hints(source) + # first's arrow should be removed + assert "-> str" not in stripped + # second must remain unchanged — no spurious removal + assert "def second():" in stripped + assert 'return "hi"' in stripped + + def test_non_ascii_default_value(self): + """Non-ASCII characters before annotations must not shift removal offsets.""" + source = 'def greet(label: str = "caf\u00e9", count: int = 1) -> str:\n pass\n' + stripped = _strip_type_hints(source) + assert ": str" not in stripped + assert ": int" not in stripped + assert "-> str" not in stripped + assert '"caf\u00e9"' in stripped + assert "count=" in stripped or "count =" in stripped + + +# ============================================================================ +# Code generation tests +# ============================================================================ + + +class TestCodeGeneration: + def _make_spec(self) -> FunctionSpec: + return FunctionSpec( + name="my_func", + component_name="My func", + description="Test function.", + params=[ + ParamInfo( + name="input_data", + yaml_name="input_data", + python_type="str", + tangle_type="String", + kind="input", + deserializer="str", + ), + ParamInfo( + name="count", + yaml_name="count", + python_type="int", + tangle_type="Integer", + kind="input", + deserializer="int", + optional=True, + default=5, + ), + ParamInfo( + name="output_path", + yaml_name="output", + python_type="OutputPath", + tangle_type="Text", + kind="output", + deserializer="_make_parent_dirs_and_return_path", + ), + ], + source_code_stripped="def my_func(input_data, count = 5, output_path):\n pass\n", + ) + + def test_argparse_generation(self): + spec = self._make_spec() + code = _build_argparse_code(spec) + assert "import argparse" in code + assert '"--input-data"' in code + assert '"--count"' in code + assert '"--output"' in code + assert "required=True" in code + assert "required=False" in code + assert "_outputs = my_func(**_parsed_args)" in code + + def test_args_section(self): + spec = self._make_spec() + args = _build_args_section(spec) + + # Required input: flat flag + placeholder + assert "--input-data" in args + assert {"inputValue": "input_data"} in args + + # Optional input: wrapped in if/cond + optional_args = [a for a in args if isinstance(a, dict) and "if" in a] + assert len(optional_args) == 1 + assert optional_args[0]["if"]["cond"] == {"isPresent": "count"} + + # Output: flat flag + outputPath placeholder + assert "--output" in args + assert {"outputPath": "output"} in args + + def test_pip_install_command(self): + cmd = _build_pip_install_command(["pandas==2.0", "requests"]) + assert cmd[0] == "sh" + assert cmd[1] == "-c" + assert "pandas==2.0" in cmd[2] + assert "requests" in cmd[2] + assert "--user" in cmd[2] + + def test_pip_install_empty(self): + assert _build_pip_install_command([]) == [] + + def test_python_source_inline(self): + spec = self._make_spec() + source = _build_python_source(spec, mode="inline") + assert "_make_parent_dirs_and_return_path" in source + assert "import argparse" in source + assert "my_func(**_parsed_args)" in source + assert "bundle" not in source or True # no bundle-specific imports in inline mode + + def test_python_source_bundle(self): + spec = self._make_spec() + source = _build_python_source(spec, mode="bundle", bundled_modules_b64="dGVzdA==") + assert "_make_parent_dirs_and_return_path" in source + assert "_EMBEDDED_MODULES" in source + assert "sys.modules" in source + assert "dGVzdA==" in source + # Main function source still present + assert "import argparse" in source + + +# ============================================================================ +# Build component dict tests +# ============================================================================ + + +class TestBuildComponentDict: + def test_basic_component(self): + spec = FunctionSpec( + name="simple_func", + component_name="Simple func", + description="A simple component.", + params=[ + ParamInfo( + name="name", + yaml_name="name", + python_type="str", + tangle_type="String", + kind="input", + deserializer="str", + ), + ParamInfo( + name="output_path", + yaml_name="output", + python_type="OutputPath", + tangle_type="Text", + kind="output", + deserializer="_make_parent_dirs_and_return_path", + ), + ], + source_code_stripped="def simple_func(name, output_path):\n pass\n", + ) + component = build_component_dict( + spec=spec, + container_image="python:3.12", + dependencies=["requests"], + annotations={"cloud_pipelines.net": "true"}, + mode="inline", + ) + + assert component["name"] == "Simple func" + assert component["description"] == "A simple component." + assert len(component["inputs"]) == 1 + assert component["inputs"][0]["name"] == "name" + assert component["inputs"][0]["type"] == "String" + assert len(component["outputs"]) == 1 + assert component["outputs"][0]["name"] == "output" + assert component["outputs"][0]["type"] == "Text" + + impl = component["implementation"]["container"] + assert impl["image"] == "python:3.12" + assert len(impl["command"]) > 0 + assert len(impl["args"]) > 0 + + def test_missing_docstring_falls_back_to_placeholder_description(self): + """Regression test for functions without docstrings. + + When a function has no docstring, ``spec.description`` is ``None``. + Without a fallback, the generated YAML emits ``description: null``, + which Tangle's schema validator rejects with + ``Expected string, received null``. The placeholder ensures the + generated YAML is always loadable in the Tangle UI. + """ + + def do(): # no docstring + pass + + spec = extract_interface(do, {}) + assert spec.description is None # sanity check on the input + + component = build_component_dict( + spec=spec, + container_image="python:3.12", + dependencies=[], + annotations={}, + mode="inline", + ) + + # Description must be present and non-empty — we don't pin its exact wording. + assert component.get("description") + + def test_docstring_description_overrides_placeholder(self): + """When a docstring is present, it wins over the placeholder fallback.""" + + def do(): + """This function does something.""" + pass + + spec = extract_interface(do, {}) + component = build_component_dict( + spec=spec, + container_image="python:3.12", + dependencies=[], + annotations={}, + mode="inline", + ) + + assert component["description"] == "This function does something." + + def test_bundle_embeds_modules(self): + def func(x: str): + """Test.""" + pass + + spec = extract_interface(func, {}) + component = build_component_dict( + spec=spec, + container_image="python:3.12", + dependencies=["pandas"], + annotations={}, + mode="bundle", + bundled_modules_b64="dGVzdA==", + ) + + # The embedded source should contain the injection code + python_source = component["implementation"]["container"]["command"][-1] + assert "_EMBEDDED_MODULES" in python_source + assert "sys.modules" in python_source + + def test_bundle_collects_imports_from_stripped_runtime_source_only(self, tmp_path): + import base64 + import re as _re + import zlib + + (tmp_path / "runtime_helper.py").write_text('VALUE = "runtime"\n', encoding="utf-8") + (tmp_path / "tangle_deploy" / "python_pipeline").mkdir(parents=True) + (tmp_path / "tangle_deploy" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "tangle_deploy" / "python_pipeline" / "__init__.py").write_text(textwrap.dedent("""\ + class TaskEnv: + def __init__(self, **kwargs): + pass + + def task(**kwargs): + def decorator(fn): + return fn + return decorator + """), encoding="utf-8") + (tmp_path / "authoring_envs.py").write_text(textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv + + UPI = TaskEnv(image="python:3.12") + """), encoding="utf-8") + py_file = tmp_path / "component.py" + py_file.write_text(textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + from authoring_envs import UPI + import runtime_helper + + @task(env=UPI) + def my_component() -> str: + return runtime_helper.VALUE + """), encoding="utf-8") + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="my_component", + mode="bundle", + ) + + assert success is True + with open(output_file) as f: + component = yaml.safe_load(f) + python_source = component["implementation"]["container"]["command"][-1] + match = _re.search(r"base64\.b64decode\('([A-Za-z0-9+/=]+)'\)", python_source) + assert match is not None + embedded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + assert "runtime_helper" in embedded + assert "authoring_envs" not in embedded + + + def test_bundle_yaml_orders_dependencies_before_dependents(self, tmp_path): + """End-to-end YAML check for issue #30197. + + Generates a real component YAML, decodes the embedded module + bundle, and verifies that a module-level dependency sorts before + its dependent in the embedded dict. Before the topological + ordering fix, the alphabetical sort placed ``aaa`` (which calls + ``bbb.bar()`` at module load) before ``bbb``, causing an + ``AttributeError`` at component runtime. + """ + import base64 + import zlib + + (tmp_path / "aaa.py").write_text(textwrap.dedent("""\ + import bbb + + FOO = bbb.bar() + + def foo(): + return FOO + """)) + (tmp_path / "bbb.py").write_text(textwrap.dedent("""\ + def bar(): + return "BIZ" + """)) + py_file = tmp_path / "component.py" + py_file.write_text(textwrap.dedent("""\ + import aaa + + def my_component() -> str: + \"\"\"Use aaa.\"\"\" + return aaa.foo() + """)) + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="my_component", + dependencies_from=toml_file, + mode="bundle", + ) + assert success is True + + with open(output_file) as f: + component = yaml.safe_load(f) + python_source = component["implementation"]["container"]["command"][-1] + + # Pull the base64 blob out of the generated source by re-running + # the same expression the injection snippet uses (the blob is + # quoted via ``repr`` in the source). + import re as _re + + # The injection emits ``base64.b64decode('')`` — the b64 + # alphabet is ``[A-Za-z0-9+/=]``, never a single quote. + match = _re.search(r"base64\.b64decode\('([A-Za-z0-9+/=]+)'\)", python_source) + assert match is not None, "injection snippet must contain a b64 blob" + embedded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + order = list(embedded.keys()) + + assert order.index("bbb") < order.index("aaa"), f"bbb must execute before aaa (got order: {order})" + + +# ============================================================================ +# Import classification tests +# ============================================================================ + + +class TestClassifyImports: + def test_stdlib_import(self, tmp_path): + source = "import os\nimport json\n" + py_file = tmp_path / "test.py" + py_file.write_text(source) + + result = ModuleBundler.classify_imports(py_file) + assert result["os"] == "stdlib" + assert result["json"] == "stdlib" + + def test_local_import(self, tmp_path): + # Create sibling module + (tmp_path / "utils.py").write_text("def helper(): pass\n") + + source = "from utils import helper\n" + py_file = tmp_path / "main.py" + py_file.write_text(source) + + result = ModuleBundler.classify_imports(py_file) + assert result["utils"] == "local" + + def test_third_party_import(self, tmp_path): + source = "import pandas\nimport requests\n" + py_file = tmp_path / "test.py" + py_file.write_text(source) + + result = ModuleBundler.classify_imports(py_file, pip_deps=["pandas==2.0", "requests>=2.28"]) + assert result["pandas"] == "third_party" + assert result["requests"] == "third_party" + + def test_relative_import(self, tmp_path): + source = "from . import helpers\n" + py_file = tmp_path / "test.py" + py_file.write_text(source) + + result = ModuleBundler.classify_imports(py_file) + assert result["helpers"] == "local" + + def test_importlib_fallback_for_sibling_directory(self, tmp_path): + """When a local module is NOT in file_dir but IS on sys.path, importlib finds it.""" + import sys as _sys + + # Use unique package name to avoid collisions + pkg_name = f"_test_classify_utils_{id(tmp_path)}" + src = tmp_path / "src" + (src / "components").mkdir(parents=True) + (src / pkg_name).mkdir(parents=True) + (src / pkg_name / "__init__.py").write_text("def helper(): pass\n") + + py_file = src / "components" / "component.py" + py_file.write_text(f"from {pkg_name} import helper\n") + + # Without sys.path modification, package won't be found from components/ + result = ModuleBundler.classify_imports(py_file) + assert result[pkg_name] == "third_party", "Without sys.path containing src/, package should be third_party" + + # With src/ on sys.path, importlib fallback should find it + _sys.path.insert(0, str(src)) + try: + result = ModuleBundler.classify_imports(py_file) + assert result[pkg_name] == "local", "With src/ on sys.path, importlib should classify package as local" + finally: + _sys.path.remove(str(src)) + for mod in list(_sys.modules): + if mod.startswith(pkg_name): + del _sys.modules[mod] + + def test_importlib_fallback_ignores_site_packages(self, tmp_path): + """Modules in site-packages should NOT be classified as local by the importlib fallback.""" + from tangle_cli.module_bundler import _is_local_via_importlib + + # json is stdlib, not in site-packages — but it's already handled by stdlib check. + # numpy/pandas (if installed) would be in site-packages. + # We test that _is_local_via_importlib returns False for known third-party packages. + # Use 'ast' (stdlib) as a safe module that find_spec will find but isn't in site-packages. + # The key point: the function should return False for things in site-packages. + assert _is_local_via_importlib("_nonexistent_module_xyz_") is False + + +# ============================================================================ +# Dependencies reading tests +# ============================================================================ + + +class TestReadDependencies: + def test_pyproject_toml(self, tmp_path): + toml_content = '[project]\nname = "test"\ndependencies = ["pandas==2.0", "requests"]\n' + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text(toml_content) + + deps = read_dependencies(toml_file) + assert deps == ["pandas==2.0", "requests"] + + def test_empty_deps(self, tmp_path): + toml_content = '[project]\nname = "test"\n' + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text(toml_content) + + deps = read_dependencies(toml_file) + assert deps == [] + + +# ============================================================================ +# Default serialization tests +# ============================================================================ + + +class TestSerializeDefault: + def test_string(self): + assert _serialize_default("hello", "String") == "hello" + + def test_int(self): + assert _serialize_default(5, "Integer") == "5" + + def test_float(self): + assert _serialize_default(3.14, "Float") == "3.14" + + def test_bool(self): + assert _serialize_default(True, "Boolean") == "True" + + def test_none(self): + assert _serialize_default(None, "String") is None + + def test_empty(self): + assert _serialize_default(inspect.Parameter.empty, "String") is None + + +# ============================================================================ +# End-to-end generation tests +# ============================================================================ + + +class TestEndToEnd: + def test_inline_generation(self, tmp_path): + """Test full inline generation pipeline.""" + py_file = tmp_path / "my_component.py" + py_file.write_text(textwrap.dedent("""\ + def my_component(name: str, count: int = 5): + \"\"\"A test component. + + Args: + name: The input name. + count: How many times. + \"\"\" + return f"{name}: {count}" + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="my_component", + dependencies_from=toml_file, + mode="inline", + ) + + assert success is True + assert output_file.exists() + + with open(output_file) as f: + component = yaml.safe_load(f) + + assert component["name"] == "My component" + assert component["description"] == "A test component." + assert len(component["inputs"]) == 2 + assert component["inputs"][0]["name"] == "name" + assert component["inputs"][0]["type"] == "String" + assert component["inputs"][1]["name"] == "count" + assert component["inputs"][1]["type"] == "Integer" + assert component["inputs"][1]["optional"] is True + assert component["inputs"][1]["default"] == "5" + + # Check implementation structure + impl = component["implementation"]["container"] + assert impl["image"] == "python:3.12" + command = impl["command"] + # Should have shell bootstrap + python source + assert "program_path=$(mktemp)" in command[-2] + python_source = command[-1] + assert "my_component" in python_source + assert "import argparse" in python_source + + # Check annotations + annotations = component["metadata"]["annotations"] + assert annotations["python_original_code_path"] == "my_component.py" + assert "my_component" in annotations["python_original_code"] + + def test_bundle_generation_with_local_import(self, tmp_path): + """Test bundle mode with a cross-module import.""" + # Use unique module name to avoid sys.modules cache conflicts with other tests + (tmp_path / "greeter_utils.py").write_text(textwrap.dedent("""\ + def greet(name): + return f"Hello, {name}!" + """)) + + py_file = tmp_path / "my_component.py" + py_file.write_text(textwrap.dedent("""\ + from greeter_utils import greet + + def my_component(name: str): + \"\"\"Greet someone.\"\"\" + return greet(name) + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="my_component", + dependencies_from=toml_file, + mode="bundle", + ) + + assert success is True + assert output_file.exists() + + with open(output_file) as f: + component = yaml.safe_load(f) + + # Check embedded source injection is present + python_source = component["implementation"]["container"]["command"][-1] + assert "_EMBEDDED_MODULES" in python_source + assert "sys.modules" in python_source + assert "types.ModuleType" in python_source + + # Main function source should still be readable + assert "my_component" in python_source + assert "import argparse" in python_source + + # Annotations should list embedded modules + annotations = component["metadata"]["annotations"] + assert "bundled_modules" in annotations + modules_list = json.loads(annotations["bundled_modules"]) + assert "greeter_utils" in modules_list + + def test_bundle_generation_with_submodule_import(self, tmp_path): + """Test that `from pkg.sub import mod` bundles the submodule file. + + When the imported name is a real submodule (e.g. local_modules/dw/utils.py), + _collect_full_module_paths must add both 'local_modules.dw' and + 'local_modules.dw.utils' so the submodule is bundled. + """ + # Create local_modules/dw/__init__.py and local_modules/dw/utils.py + pkg_dir = tmp_path / "local_modules" / "dw" + pkg_dir.mkdir(parents=True) + (tmp_path / "local_modules" / "__init__.py").write_text("") + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "utils.py").write_text(textwrap.dedent("""\ + def helper(): + return "helped" + """)) + + py_file = tmp_path / "my_component.py" + py_file.write_text(textwrap.dedent("""\ + from local_modules.dw import utils + + def my_component(name: str): + \"\"\"Use a submodule.\"\"\" + return utils.helper() + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="my_component", + dependencies_from=toml_file, + mode="bundle", + ) + + assert success is True + assert output_file.exists() + + with open(output_file) as f: + component = yaml.safe_load(f) + + python_source = component["implementation"]["container"]["command"][-1] + assert "_EMBEDDED_MODULES" in python_source + + # The submodule utils.py must be bundled + annotations = component["metadata"]["annotations"] + modules_list = json.loads(annotations["bundled_modules"]) + assert "local_modules.dw.utils" in modules_list + + def test_bundle_collects_parent_init_files(self, tmp_path): + """Test that bundle mode collects all parent __init__.py files.""" + # Create a 3-level package: pkg/sub/mod.py + (tmp_path / "pkg" / "sub").mkdir(parents=True) + (tmp_path / "pkg" / "__init__.py").write_text("TOP = 1") + (tmp_path / "pkg" / "sub" / "__init__.py").write_text("") + (tmp_path / "pkg" / "sub" / "mod.py").write_text("def fn(): pass") + + py_file = tmp_path / "comp.py" + py_file.write_text("from pkg.sub import mod\ndef comp(): return mod.fn()\n") + + sources = ModuleBundler.collect_sources(py_file) + assert "pkg" in sources, "parent __init__.py must be collected" + assert sources["pkg"] == "TOP = 1" + + def test_bundle_follows_transitive_imports_in_parent_init(self, tmp_path): + """Test that imports inside parent __init__.py files are followed. + + Regression test: parent __init__.py files may import sibling modules + (e.g. ``from . import helpers``). These must be collected, otherwise + the bundle crashes at runtime with ImportError. + """ + import base64 + import zlib + + # mylib/__init__.py imports helpers; component only imports mylib.core + (tmp_path / "mylib").mkdir() + (tmp_path / "mylib" / "__init__.py").write_text("from . import helpers\n") + (tmp_path / "mylib" / "helpers.py").write_text("HELP = True\n") + (tmp_path / "mylib" / "core.py").write_text("def process(): pass\n") + + py_file = tmp_path / "component.py" + py_file.write_text("from mylib.core import process\n") + + sources = ModuleBundler.collect_sources(py_file) + assert "mylib.core" in sources + assert "mylib" in sources, "parent __init__.py must be collected" + assert "mylib.helpers" in sources, "sibling module imported by parent __init__.py must be collected" + + # The encoded bundle must put ``mylib.helpers`` before ``mylib`` + # because ``mylib/__init__.py`` does ``from . import helpers`` at + # module load time — a dependent must never sort before its + # dependency in the embedded dict (issue #30197). + b64 = ModuleBundler.encode(sources) + assert b64 is not None + order = list(json.loads(zlib.decompress(base64.b64decode(b64))).keys()) + assert order.index("mylib.helpers") < order.index( + "mylib" + ), f"mylib.helpers must execute before mylib (got order: {order})" + + def test_bundle_with_sibling_directory_via_importlib(self, tmp_path): + """Test bundle mode discovers modules in sibling directories via importlib. + + Reproduces the project layout from GitHub issue #28707: + src/ + components/ + component_one.py (imports utils) + utils/ + __init__.py + utility_function.py + """ + import sys as _sys + + src = tmp_path / "src" + (src / "components").mkdir(parents=True) + # Use a unique package name to avoid collisions with real packages + pkg_name = f"_test_sibling_utils_{id(tmp_path)}" + (src / pkg_name).mkdir(parents=True) + (src / pkg_name / "__init__.py").write_text(f"from {pkg_name}.utility_function import do_something\n") + (src / pkg_name / "utility_function.py").write_text(textwrap.dedent("""\ + def do_something(x): + return x + 1 + """)) + + py_file = src / "components" / "component_one.py" + py_file.write_text(textwrap.dedent(f"""\ + from {pkg_name} import do_something + + def component_one(value: int) -> int: + \"\"\"Process a value.\"\"\" + return do_something(value) + """)) + + # Add src/ to sys.path so importlib can find the package + _sys.path.insert(0, str(src)) + try: + sources = ModuleBundler.collect_sources(py_file) + assert pkg_name in sources, "importlib fallback should discover package in sibling directory" + assert f"{pkg_name}.utility_function" in sources, "transitive import within __init__.py should be followed" + finally: + _sys.path.remove(str(src)) + for mod in list(_sys.modules): + if mod.startswith(pkg_name): + del _sys.modules[mod] + + def test_bundle_with_sibling_directory_via_resolve_root(self, tmp_path): + """Test bundle mode discovers sibling modules via explicit resolve_root. + + Same layout as test_bundle_with_sibling_directory_via_importlib but + uses the resolve_root parameter instead of relying on sys.path. + """ + src = tmp_path / "src" + (src / "components").mkdir(parents=True) + (src / "utils").mkdir(parents=True) + (src / "utils" / "__init__.py").write_text("from utils.utility_function import do_something\n") + (src / "utils" / "utility_function.py").write_text(textwrap.dedent("""\ + def do_something(x): + return x + 1 + """)) + + py_file = src / "components" / "component_one.py" + py_file.write_text(textwrap.dedent("""\ + from utils import do_something + + def component_one(value: int) -> int: + \"\"\"Process a value.\"\"\" + return do_something(value) + """)) + + # Use resolve_root=src/ so filesystem check finds utils/ + sources = ModuleBundler.collect_sources(py_file, resolve_root=src) + assert "utils" in sources, "resolve_root=src should find utils in sibling directory" + assert "utils.utility_function" in sources + + def test_bundle_end_to_end_sibling_directory(self, tmp_path): + """End-to-end test: generate_component_yaml with sibling directory imports. + + Tests the full pipeline with ``resolve_root`` — does NOT manually modify + ``sys.path``. This proves that ``--resolve-root`` alone is sufficient: + ``load_python_module`` and ``ModuleBundler.collect_sources`` both use + it to find sibling packages. + """ + import sys as _sys + + src = tmp_path / "src" + (src / "components").mkdir(parents=True) + # Use a unique package name to avoid collisions with real packages + pkg_name = f"_test_utils_{id(tmp_path)}" + (src / pkg_name).mkdir(parents=True) + (src / pkg_name / "__init__.py").write_text("CONSTANT = 42\n") + + py_file = src / "components" / "component_one.py" + py_file.write_text(textwrap.dedent(f"""\ + from {pkg_name} import CONSTANT + + def component_one(value: int) -> int: + \"\"\"Add constant.\"\"\" + return value + CONSTANT + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + # Note: sys.path is NOT modified — resolve_root should handle everything + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="component_one", + dependencies_from=toml_file, + mode="bundle", + resolve_root=src, + ) + + # Clean up sys.modules to avoid leaking into other tests + for mod_name in list(_sys.modules): + if mod_name.startswith(pkg_name): + del _sys.modules[mod_name] + + assert success is True + assert output_file.exists() + + with open(output_file) as f: + component = yaml.safe_load(f) + + python_source = component["implementation"]["container"]["command"][-1] + assert "_EMBEDDED_MODULES" in python_source + + annotations = component["metadata"]["annotations"] + assert "bundled_modules" in annotations + modules_list = json.loads(annotations["bundled_modules"]) + assert pkg_name in modules_list + + def test_generation_with_output_path(self, tmp_path): + """Test component with OutputPath annotation.""" + py_file = tmp_path / "writer.py" + py_file.write_text(textwrap.dedent("""\ + class OutputPath: + def __init__(self, type=None): + self.type = type + + def write_data(data: str, result_path: OutputPath("Text")): + \"\"\"Write data to output.\"\"\" + with open(result_path, "w") as f: + f.write(data) + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="write_data", + dependencies_from=toml_file, + ) + + assert success is True + + with open(output_file) as f: + component = yaml.safe_load(f) + + assert len(component["inputs"]) == 1 + assert component["inputs"][0]["name"] == "data" + + assert len(component["outputs"]) == 1 + assert component["outputs"][0]["name"] == "result" # _path stripped + assert component["outputs"][0]["type"] == "Text" + + # Check args section has outputPath + args = component["implementation"]["container"]["args"] + has_output_path = any(isinstance(a, dict) and "outputPath" in a for a in args) + assert has_output_path + + def test_main_guard_stripped_from_generated_source(self, tmp_path): + """Test that if __name__ == "__main__" blocks are stripped during generation.""" + py_file = tmp_path / "guarded.py" + py_file.write_text(textwrap.dedent("""\ + import sys + + def guarded(name: str): + \"\"\"A component with a main guard.\"\"\" + return name + + if __name__ == "__main__": + print("ERROR: This script should be called through Tangle, not directly") + sys.exit(1) + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="guarded", + dependencies_from=toml_file, + mode="inline", + ) + + assert success is True + + with open(output_file) as f: + component = yaml.safe_load(f) + + python_source = component["implementation"]["container"]["command"][-1] + assert "if __name__" not in python_source + assert "sys.exit(1)" not in python_source + # The argparse wrapper should still be present + assert "import argparse" in python_source + assert "guarded" in python_source + + +# ============================================================================ +# _strip_main_guard tests +# ============================================================================ + + +class TestStripMainGuard: + def test_strips_simple_guard(self): + source = textwrap.dedent("""\ + def hello(): + pass + + if __name__ == "__main__": + hello() + """) + result = _strip_main_guard(source) + assert "__name__" not in result + assert "def hello" in result + + def test_strips_guard_with_sys_exit(self): + source = textwrap.dedent("""\ + import sys + + def my_func(): + pass + + if __name__ == "__main__": + print("ERROR: not directly") + sys.exit(1) + """) + result = _strip_main_guard(source) + assert "__name__" not in result + assert "sys.exit" not in result + assert "def my_func" in result + assert "import sys" in result + + def test_strips_reversed_comparison(self): + source = textwrap.dedent("""\ + def hello(): + pass + + if "__main__" == __name__: + hello() + """) + result = _strip_main_guard(source) + assert "__name__" not in result + + def test_preserves_code_without_guard(self): + source = textwrap.dedent("""\ + def hello(): + pass + + x = 1 + """) + assert _strip_main_guard(source) == source + + def test_handles_syntax_error(self): + source = "def broken(:\n" + assert _strip_main_guard(source) == source + + +# ============================================================================ +# _strip_authoring_constructs tests +# ============================================================================ + + +class TestStripAuthoringConstructs: + """Unit tests for the authoring import + decorator strip (§0.2).""" + + def test_strips_from_import_and_simple_decorator(self): + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + @task(image="python:3.12") + def hello(out, who="world"): + with open(out, "w") as f: + f.write(who) + """) + result = _strip_authoring_constructs(source) + assert "from tangle_deploy" not in result + assert "@task" not in result + assert 'def hello(out, who="world"):' in result + # The runtime body survives untouched. + assert "f.write(who)" in result + + def test_strips_multiline_decorator(self): + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + @task( + image="python:3.12", + ) + def hello(out): + pass + """) + result = _strip_authoring_constructs(source) + assert "@task" not in result + assert 'image="python:3.12"' not in result + assert "def hello(out):" in result + + def test_strips_pipeline_and_subpipeline_decorators(self): + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import pipeline, subpipeline + + @pipeline("My Pipeline") + def my_pipeline(cfg): + return None + + @subpipeline("Nested") + def nested(cfg): + return None + """) + result = _strip_authoring_constructs(source) + assert "@pipeline" not in result + assert "@subpipeline" not in result + assert "from tangle_deploy" not in result + assert "def my_pipeline(cfg):" in result + assert "def nested(cfg):" in result + + def test_strips_dotted_decorator_form(self): + source = textwrap.dedent("""\ + import tangle_deploy.python_pipeline as tp + + @tp.task(image="python:3.12") + def hello(out): + pass + """) + result = _strip_authoring_constructs(source) + assert "import tangle_deploy" not in result + assert "@tp.task" not in result + assert "def hello(out):" in result + + def test_strips_aliased_import(self): + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import ref as operation_by_ref + + x = 1 + """) + result = _strip_authoring_constructs(source) + assert "operation_by_ref" not in result + assert "from tangle_deploy" not in result + assert "x = 1" in result + + def test_strips_plain_import_of_python_pipeline(self): + source = textwrap.dedent("""\ + import tangle_deploy.python_pipeline + + x = 1 + """) + result = _strip_authoring_constructs(source) + assert "import tangle_deploy.python_pipeline" not in result + assert "x = 1" in result + + def test_preserves_non_authoring_tangle_deploy_import(self): + # FIX 1: only tangle_deploy.python_pipeline is authoring. A genuine + # runtime helper from another tangle_deploy.* package must survive, + # otherwise the baked program raises NameError at runtime. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + from tangle_deploy.utils import something + + @task(image="python:3.12") + def hello(out): + return something(out) + """) + result = _strip_authoring_constructs(source) + # Authoring import + decorator gone. + assert "from tangle_deploy.python_pipeline import task" not in result + assert "@task" not in result + # Non-authoring helper import preserved, body still references it. + assert "from tangle_deploy.utils import something" in result + assert "return something(out)" in result + + def test_preserves_bare_tangle_deploy_import(self): + # A bare ``import tangle_deploy`` is not the authoring module; preserve it. + source = textwrap.dedent("""\ + import tangle_deploy + + x = tangle_deploy.__name__ + """) + result = _strip_authoring_constructs(source) + assert result == source + + def test_preserves_unrelated_imports_and_decorators(self): + source = textwrap.dedent("""\ + import os + from functools import lru_cache + from . import sibling + + @lru_cache(maxsize=None) + def cached(x): + return x + + @property + def prop(self): + return 1 + """) + result = _strip_authoring_constructs(source) + # Nothing authoring-related here, so the source is unchanged. + assert result == source + + def test_body_using_task_pipeline_identifiers_not_corrupted(self): + # FIX 3(a): identifiers/strings named task/pipeline in the BODY must not + # be touched — only the decorator + authoring import lines are removed. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + @task(image="python:3.12") + def run(out): + task = "build the pipeline" + pipeline = ["task", "pipeline"] + note = "run @task then @pipeline" + return task, pipeline, note + """) + result = _strip_authoring_constructs(source) + # Decorator + import removed. + assert "from tangle_deploy" not in result + assert "@task(" not in result + # Body assignments/strings using these names survive verbatim. + assert 'task = "build the pipeline"' in result + assert 'pipeline = ["task", "pipeline"]' in result + assert 'note = "run @task then @pipeline"' in result + assert "return task, pipeline, note" in result + + def test_multi_name_authoring_import_line_dropped(self): + # FIX 3(b): a multi-name authoring import drops the WHOLE line. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import In, Out, task + + @task(image="python:3.12") + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "In" not in result + assert "Out" not in result + assert "from tangle_deploy" not in result + assert "@task" not in result + assert "def hello(out):" in result + + def test_unrelated_decorator_preserved_alongside_task(self): + # FIX 3(c): @task is stripped but a stacked unrelated decorator stays. + source = textwrap.dedent("""\ + import functools + + from tangle_deploy.python_pipeline import task + + @task(image="python:3.12") + @functools.cache + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "@task" not in result + assert "from tangle_deploy" not in result + # Unrelated decorator + its import survive. + assert "@functools.cache" in result + assert "import functools" in result + assert "def hello(out):" in result + + def test_strips_bare_authoring_decorator(self): + # FIX 3(d): a bare @task (no call) is still removed. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + @task + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "@task" not in result + assert "from tangle_deploy" not in result + assert "def hello(out):" in result + + def test_preserves_comments_and_formatting_in_body(self): + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + # a leading comment that must survive + @task(image="python:3.12") + def hello(out): + # inline comment + value = 1 # trailing comment + return value + """) + result = _strip_authoring_constructs(source) + assert "# a leading comment that must survive" in result + assert "# inline comment" in result + assert "value = 1 # trailing comment" in result + assert "@task" not in result + + def test_handles_syntax_error(self): + source = "def broken(:\n" + assert _strip_authoring_constructs(source) == source + + def test_no_authoring_constructs_is_noop(self): + source = textwrap.dedent("""\ + import os + + def plain(x): + return os.path.join(x, "y") + """) + assert _strip_authoring_constructs(source) == source + + +# ============================================================================ +# _strip_authoring_constructs — TaskEnv env-only hardening (Phase 3, §3.5) +# ============================================================================ + + +class TestStripTaskEnvAuthoring: + """Unit tests for the TaskEnv env-only strip extension (§3.5). + + These feed source text directly to ``_strip_authoring_constructs`` and + assert that env-only authoring declarations/imports used by a stripped + ``@task(env=...)`` decorator are also removed (or fail fast), while the + general strip behaviour and unrelated runtime code are untouched. + """ + + def test_colocated_env_assignment_is_stripped(self): + # UPI = TaskEnv(...) co-located with @task(env=UPI): the assignment, the + # authoring import, and the decorator must all be gone. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI = TaskEnv(image="python:3.12") + + @task(env=UPI) + def hello(out, who="world"): + with open(out, "w") as f: + f.write(who) + """) + result = _strip_authoring_constructs(source) + assert "TaskEnv" not in result + assert "UPI" not in result + assert "from tangle_deploy" not in result + assert "@task" not in result + # Runtime function + body survive. + assert 'def hello(out, who="world"):' in result + assert "f.write(who)" in result + + def test_annotated_env_assignment_is_stripped(self): + # UPI: TaskEnv = TaskEnv(...) annotated form is stripped too. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI: TaskEnv = TaskEnv(image="python:3.12") + + @task(env=UPI) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "TaskEnv" not in result + assert "UPI" not in result + assert "def hello(out):" in result + + def test_factory_built_env_assignment_is_stripped(self): + # UPI = make_task_env(...) is stripped because UPI is collected from + # @task(env=UPI) (target-name rule), even though the value is not a + # direct TaskEnv(...) call. + source = textwrap.dedent("""\ + from helpers import make_task_env + from tangle_deploy.python_pipeline import task + + UPI = make_task_env("python:3.12") + + @task(env=UPI) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + # The env assignment (UPI) and the decorator are stripped because UPI + # was collected from @task(env=UPI), even though the value is a factory + # call rather than a direct TaskEnv(...). + assert "UPI = make_task_env" not in result + assert "UPI" not in result + assert "@task" not in result + assert "from tangle_deploy" not in result + # The factory import itself is NOT a collected env name, so it is + # deliberately preserved -- the strip is not a broad unused-import + # cleaner. Authors keep factory helpers runtime-available. + assert "from helpers import make_task_env" in result + assert "def hello(out):" in result + + def test_imported_env_name_is_stripped(self): + # from _envs import UPI + @task(env=UPI): the sibling import is gone. + source = textwrap.dedent("""\ + from _envs import UPI + from tangle_deploy.python_pipeline import task + + @task(env=UPI) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "from _envs import UPI" not in result + assert "UPI" not in result + assert "@task" not in result + assert "def hello(out):" in result + + def test_imported_env_module_alias_is_stripped(self): + # import _envs + @task(env=_envs.UPI): the module import is gone. + source = textwrap.dedent("""\ + import _envs + from tangle_deploy.python_pipeline import task + + @task(env=_envs.UPI) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "import _envs" not in result + assert "_envs" not in result + assert "@task" not in result + assert "def hello(out):" in result + + def test_aliased_env_module_import_is_stripped(self): + # import envs as task_envs + @task(env=task_envs.UPI): the aliased + # module import is collected by its bound name and stripped. + source = textwrap.dedent("""\ + import envs as task_envs + from tangle_deploy.python_pipeline import task + + @task(env=task_envs.UPI) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "task_envs" not in result + assert "import envs" not in result + assert "def hello(out):" in result + + def test_inline_taskenv_leaves_no_residual(self): + # @task(env=TaskEnv(...)) inline: the whole decorator range is deleted, + # so no TaskEnv text survives. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + @task(env=TaskEnv(image="python:3.12")) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "TaskEnv" not in result + assert "@task" not in result + assert "from tangle_deploy" not in result + assert "def hello(out):" in result + + def test_explicit_image_override_still_strips_env_decl(self): + # @task(env=UPI, image="override"): decorator + UPI decl gone. The + # sidecar image override is a Phase 2 concern; here the baked source + # must just be env-free. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI = TaskEnv(image="python:3.12") + + @task(env=UPI, image="python:3.13-slim") + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "TaskEnv" not in result + assert "UPI" not in result + assert "python:3.13-slim" not in result # lived only in the decorator + assert "@task" not in result + assert "def hello(out):" in result + + def test_direct_taskenv_assignment_without_decorator_is_stripped(self): + # A module-level X = TaskEnv(...) is authoring-only by contract even + # with no @task(env=X) referencing it -- the direct-construction rule + # removes it. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv + + SHARED = TaskEnv(image="python:3.12") + + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "TaskEnv" not in result + assert "SHARED" not in result + assert "def hello(out):" in result + + def test_preserves_unrelated_runtime_declarations(self): + # Only env-only names are stripped; unrelated runtime constants, + # imports and helpers survive verbatim. + source = textwrap.dedent("""\ + import os + + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI = TaskEnv(image="python:3.12") + RETRIES = 3 + BASE_DIR = os.getcwd() + + @task(env=UPI) + def hello(out): + return os.path.join(BASE_DIR, str(RETRIES)) + """) + result = _strip_authoring_constructs(source) + # env-only constructs gone. + assert "TaskEnv" not in result + assert "UPI" not in result + # runtime constants/imports/usages survive. + assert "import os" in result + assert "RETRIES = 3" in result + assert "BASE_DIR = os.getcwd()" in result + assert "return os.path.join(BASE_DIR, str(RETRIES))" in result + + def test_fail_fast_mixed_import_with_used_runtime_name(self): + # from _envs import UPI, helper where helper is used in the body: we + # cannot line-delete part of the statement, so fail fast with split + # guidance. + source = textwrap.dedent("""\ + from _envs import UPI, helper + from tangle_deploy.python_pipeline import task + + @task(env=UPI) + def hello(out): + return helper(out) + """) + with pytest.raises(AuthoringStripError) as excinfo: + _strip_authoring_constructs(source) + msg = str(excinfo.value) + assert "helper" in msg + assert "Split the import" in msg + + def test_fail_fast_env_name_referenced_by_body(self): + # @task(env=UPI) but the body also references UPI: env names are + # authoring-only, so fail fast rather than bake a NameError. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI = TaskEnv(image="python:3.12") + + @task(env=UPI) + def hello(out): + return UPI.image + """) + with pytest.raises(AuthoringStripError) as excinfo: + _strip_authoring_constructs(source) + msg = str(excinfo.value) + assert "UPI" in msg + assert "authoring-only" in msg + + def test_fail_fast_imported_env_name_referenced_by_body(self): + # from _envs import UPI + body references UPI: fail fast (its import is + # stripped, so a kept reference would be a NameError). + source = textwrap.dedent("""\ + from _envs import UPI + from tangle_deploy.python_pipeline import task + + @task(env=UPI) + def hello(out): + return UPI.image + """) + with pytest.raises(AuthoringStripError) as excinfo: + _strip_authoring_constructs(source) + assert "UPI" in str(excinfo.value) + + def test_unused_runtime_name_on_env_import_is_dropped(self): + # from _envs import UPI, unused where ``unused`` is NOT referenced in + # kept code: the whole env import is safely removed (no fail-fast, + # since nothing runtime depends on it). + source = textwrap.dedent("""\ + from _envs import UPI, unused + from tangle_deploy.python_pipeline import task + + @task(env=UPI) + def hello(out): + return out + """) + result = _strip_authoring_constructs(source) + assert "from _envs import" not in result + assert "UPI" not in result + assert "def hello(out):" in result + + def test_annotation_only_env_reference_does_not_fail(self): + # FIX N1 (§3.5): an env name used ONLY in a type annotation (param and + # return) and NEVER in the body must NOT trip the body-ref fail-fast. + # Annotations are stripped from the baked output by _strip_type_hints + # (a separate later pass), so they are not live runtime references. + # The UPI declaration IS still stripped; the annotation survives at + # THIS layer (it is removed by the subsequent type-hint pass). + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI = TaskEnv(image="python:3.12") + + @task(env=UPI) + def hello(x: UPI, out) -> UPI: + with open(out, "w") as f: + f.write(x) + """) + # Does NOT raise. + result = _strip_authoring_constructs(source) + # The env declaration is stripped. + assert "UPI = TaskEnv" not in result + assert "TaskEnv" not in result + assert "@task" not in result + assert "from tangle_deploy" not in result + # The annotation reference is untouched here (stripped later by + # _strip_type_hints); the body + def survive. + assert "def hello(x: UPI, out) -> UPI:" in result + assert "f.write(x)" in result + # End-to-end: after the type-hint pass the program is fully env-free. + final = _strip_type_hints(result) + assert "UPI" not in final + assert "def hello(x, out):" in final + + def test_annotation_plus_body_reference_still_fails(self): + # FIX N1 guard: excluding annotation slots must NOT mask a REAL body + # reference. UPI here is in the annotation AND used in the body, so the + # fail-fast must still fire. + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import TaskEnv, task + + UPI = TaskEnv(image="python:3.12") + + @task(env=UPI) + def hello(x: UPI, out) -> UPI: + return UPI.image + """) + with pytest.raises(AuthoringStripError) as excinfo: + _strip_authoring_constructs(source) + msg = str(excinfo.value) + assert "UPI" in msg + assert "authoring-only" in msg + + def test_fail_fast_nested_env_import(self): + # FIX N2 (§3.5): an env import nested inside an `if` block is NOT a + # module-level statement, so it is never stripped and would leak into + # the baked program. The strip must FAIL FAST with actionable guidance + # rather than line-delete it (which could leave an empty block). + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + if True: + from task_env_strip_envs import UPI + + @task(env=UPI) + def hello(out): + with open(out, "w") as f: + f.write("x") + """) + with pytest.raises(AuthoringStripError) as excinfo: + _strip_authoring_constructs(source) + msg = str(excinfo.value) + assert "UPI" in msg + assert "nested" in msg + assert "module-level" in msg + assert "top-level import" in msg + + def test_nested_env_import_in_try_block_fails_fast(self): + # FIX N2: the same protection applies to imports nested in a try block + # (anything that is not a direct child of the module body). + source = textwrap.dedent("""\ + from tangle_deploy.python_pipeline import task + + try: + import task_env_strip_envs + except ImportError: + task_env_strip_envs = None + + @task(env=task_env_strip_envs.UPI) + def hello(out): + with open(out, "w") as f: + f.write("x") + """) + with pytest.raises(AuthoringStripError) as excinfo: + _strip_authoring_constructs(source) + msg = str(excinfo.value) + assert "task_env_strip_envs" in msg + assert "nested" in msg + + +# ============================================================================ +# Generator-level authoring strip tests (Phase 0d) +# ============================================================================ + + +class TestGeneratorStripsAuthoring: + """`generate_component_yaml` must bake an authoring-free runtime program + while keeping ``python_original_code`` byte-verbatim.""" + + @staticmethod + def _baked_program_and_annotations(output_file): + with open(output_file) as f: + component = yaml.safe_load(f) + program = component["implementation"]["container"]["command"][-1] + annotations = component["metadata"]["annotations"] + return program, annotations + + def test_single_function_task_file(self, tmp_path): + py_file = tmp_path / "single_task.py" + py_file.write_text(textwrap.dedent('''\ + from cloud_pipelines import components + + from tangle_deploy.python_pipeline import task + + + @task( + image="python:3.12", + ) + def single_task(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Single Task + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") + ''')) + original_bytes = py_file.read_text() + + output_file = tmp_path / "single_task.yaml" + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="single_task", + mode="inline", + ) + assert success is True + + program, annotations = self._baked_program_and_annotations(output_file) + # Baked runtime program is authoring-free. + assert "from tangle_deploy" not in program + assert "@task" not in program + # The plain runtime function survived. + assert "def single_task(" in program + assert 'fh.write(f"hi {who}")' in program + # Verbatim annotation is untouched. + assert annotations["python_original_code"] == original_bytes + + def test_colocated_task_and_pipeline_file(self, tmp_path): + py_file = tmp_path / "colocated.py" + py_file.write_text(textwrap.dedent('''\ + from cloud_pipelines import components + + from tangle_deploy.python_pipeline import Out, pipeline, task + + + @task(image="python:3.12") + def greet(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Greet + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") + + + @pipeline("Colocated Pipeline") + def colocated_pipeline(cfg) -> Out[str]: + result = greet(who="world") + return result.out + ''')) + original_bytes = py_file.read_text() + + output_file = tmp_path / "colocated.yaml" + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="greet", + mode="inline", + ) + assert success is True + + program, annotations = self._baked_program_and_annotations(output_file) + # Both co-located decorators and the authoring import are gone. + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "@pipeline" not in program + # The task's runtime function survived the strip. + assert "def greet(" in program + # Verbatim annotation keeps the full co-located source byte-for-byte. + assert annotations["python_original_code"] == original_bytes + + def test_multi_name_import_baked_program_runs(self, tmp_path): + # FIX 3(b): multi-name authoring import drops the whole line; the baked + # program has no In/Out/task left and still runs end-to-end. + py_file = tmp_path / "multi_name.py" + py_file.write_text(textwrap.dedent('''\ + from cloud_pipelines import components + + from tangle_deploy.python_pipeline import In, Out, task + + + @task(image="python:3.12") + def multi_name(out: components.OutputPath("Text"), who: str = "world"): + """ + Metadata: + Name: Multi Name + Version: 1.0.0 + """ + with open(out, "w") as fh: + fh.write(f"hi {who}") + ''')) + + output_file = tmp_path / "multi_name.yaml" + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="multi_name", + mode="inline", + ) + assert success is True + + program, _ = self._baked_program_and_annotations(output_file) + assert "from tangle_deploy" not in program + assert "@task" not in program + # None of the multi-name authoring imports leaked. + for token in (" In", " Out", " task"): + assert token not in program + + program_path = tmp_path / "inlined_multi_name.py" + program_path.write_text(program) + out_path = tmp_path / "outputs" / "hi.txt" + completed = subprocess.run( + [sys.executable, str(program_path), "--out", str(out_path), "--who", "world"], + capture_output=True, + check=False, + text=True, + ) + assert completed.returncode == 0, completed.stderr + assert out_path.read_text() == "hi world" + + +# ============================================================================ +# Generator-level TaskEnv env-only strip tests (Phase 3, §3.5) +# ============================================================================ + + +def test_generate_component_yaml_shim_supports_outputs_import(tmp_path): + source = tmp_path / "outputs_import_component.py" + source.write_text( + textwrap.dedent( + ''' + from tangle_deploy.python_pipeline import Outputs, task + + @task() + def outputs_import_component(name: str) -> str: + return name + ''' + ).lstrip() + ) + output_file = tmp_path / "component.yaml" + + assert generate_component_yaml( + source, + output_file, + container_image="python:3.12", + function_name="outputs_import_component", + ) is True + + component = yaml.safe_load(output_file.read_text()) + program = component["implementation"]["container"]["command"][-1] + assert "from tangle_deploy.python_pipeline" not in program + assert "from tangle_deploy.python_pipeline import Outputs" not in program + + +class TestGeneratorStripsTaskEnvAuthoring: + """``generate_component_yaml`` must bake an env-free runtime program for + ``@task(env=...)`` authoring, while keeping ``python_original_code`` + byte-verbatim. Drives the real ``task_env_strip_*`` fixtures end to end. + """ + + @staticmethod + def _baked_program_and_annotations(output_file): + with open(output_file) as f: + component = yaml.safe_load(f) + program = component["implementation"]["container"]["command"][-1] + annotations = component["metadata"]["annotations"] + return program, annotations + + def _generate(self, fixture, function_name, tmp_path, container_image="python:3.12"): + output_file = tmp_path / f"{function_name}.yaml" + success = generate_component_yaml( + file_path=_PIPELINE_FIXTURES / fixture, + output_path=output_file, + container_image=container_image, + function_name=function_name, + mode="inline", + ) + assert success is True, f"generate_component_yaml failed for {fixture}" + program, annotations = self._baked_program_and_annotations(output_file) + original = (_PIPELINE_FIXTURES / fixture).read_text() + return program, annotations, original + + @staticmethod + def _run_baked(program, tmp_path, stem): + """Subprocess-run a baked program; assert it writes ``hi world``.""" + program_path = tmp_path / f"inlined_{stem}.py" + program_path.write_text(program) + out_path = tmp_path / "outputs" / "hi.txt" + completed = subprocess.run( + [sys.executable, str(program_path), "--out", str(out_path), "--who", "world"], + capture_output=True, + check=False, + text=True, + ) + assert completed.returncode == 0, completed.stderr + assert out_path.read_text() == "hi world" + + def test_colocated_env_assignment_absent_from_baked_program(self, tmp_path): + program, annotations, original = self._generate( + "task_env_strip_colocated_op.py", "task_env_strip_colocated", tmp_path + ) + # Baked runtime program is env- and authoring-free. + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "TaskEnv" not in program + assert "UPI" not in program + # Plain runtime function survived. + assert "def task_env_strip_colocated(" in program + # Verbatim annotation untouched: it STILL carries the env declaration. + assert annotations["python_original_code"] == original + assert "UPI = TaskEnv(" in annotations["python_original_code"] + # Proof it actually runs (would be NameError: TaskEnv if leaked). + self._run_baked(program, tmp_path, "colocated") + + def test_imported_env_name_absent_from_baked_program(self, tmp_path): + program, annotations, original = self._generate( + "task_env_strip_imported_op.py", "task_env_strip_imported", tmp_path + ) + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "task_env_strip_envs" not in program # the authoring-only import + assert "UPI" not in program + assert "def task_env_strip_imported(" in program + # Verbatim annotation keeps the sibling import byte-for-byte. + assert annotations["python_original_code"] == original + assert "from task_env_strip_envs import UPI" in annotations["python_original_code"] + # Proof it runs without importing the authoring-only sibling module + # (would be ImportError if leaked). + self._run_baked(program, tmp_path, "imported") + + def test_imported_env_module_alias_absent_from_baked_program(self, tmp_path): + program, annotations, original = self._generate( + "task_env_strip_module_op.py", "task_env_strip_module", tmp_path + ) + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "import task_env_strip_envs" not in program + assert "task_env_strip_envs" not in program + assert "def task_env_strip_module(" in program + assert annotations["python_original_code"] == original + assert "import task_env_strip_envs" in annotations["python_original_code"] + self._run_baked(program, tmp_path, "module") + + def test_inline_taskenv_absent_from_baked_program(self, tmp_path): + program, annotations, original = self._generate( + "task_env_strip_inline_op.py", "task_env_strip_inline", tmp_path + ) + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "TaskEnv" not in program # no residual inline TaskEnv(...) text + assert "def task_env_strip_inline(" in program + assert annotations["python_original_code"] == original + assert "env=TaskEnv(" in annotations["python_original_code"] + self._run_baked(program, tmp_path, "inline") + + def test_explicit_image_override_baked_program_is_env_free(self, tmp_path): + program, annotations, original = self._generate( + "task_env_strip_override_op.py", + "task_env_strip_override", + tmp_path, + container_image="python:3.13-slim", + ) + # Decorator + env declaration absent; the override image string lived + # only in the decorator, so it must not leak into the baked source. + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "TaskEnv" not in program + assert "UPI" not in program + assert "python:3.13-slim" not in program + assert "def task_env_strip_override(" in program + # Verbatim annotation keeps both the env decl and the override. + assert annotations["python_original_code"] == original + assert "UPI = TaskEnv(" in annotations["python_original_code"] + assert "python:3.13-slim" in annotations["python_original_code"] + self._run_baked(program, tmp_path, "override") + + def test_mixed_import_fails_loud_at_generator_layer(self, tmp_path): + # A TaskEnv authoring-violation must be a HARD, LOUD failure carrying + # the actionable guidance -- NOT swallowed into warn + success=False + # (which would resurface as a confusing broken component at hydrate / + # backend run time). generate_component_yaml re-raises AuthoringStripError + # specifically while keeping warn+False for every other failure. + output_file = tmp_path / "mixed.yaml" + with pytest.raises(AuthoringStripError) as excinfo: + generate_component_yaml( + file_path=_PIPELINE_FIXTURES / "task_env_strip_mixed_import_op.py", + output_path=output_file, + container_image="python:3.12", + function_name="task_env_strip_mixed_import", + mode="inline", + ) + msg = str(excinfo.value) + assert "helper" in msg + assert "Split the import" in msg + # Hard failure: nothing was written. + assert not output_file.exists() + + def test_body_reference_fails_loud_at_generator_layer(self, tmp_path): + # An env name referenced by the task body must also fail loud. + output_file = tmp_path / "body_ref.yaml" + with pytest.raises(AuthoringStripError) as excinfo: + generate_component_yaml( + file_path=_PIPELINE_FIXTURES / "task_env_strip_body_ref_op.py", + output_path=output_file, + container_image="python:3.12", + function_name="task_env_strip_body_ref", + mode="inline", + ) + msg = str(excinfo.value) + assert "UPI" in msg + assert "authoring-only" in msg + assert not output_file.exists() + + def test_annotation_only_env_baked_program_is_env_free_and_runs(self, tmp_path): + # FIX N1 (§3.5): an env used ONLY as a type annotation (`-> UPI`) and + # NOT in the body must NOT fail fast. The baked program must be env-free + # (decorator, import, env decl, AND the annotation all gone) and run + # without a NameError; python_original_code stays byte-verbatim. + program, annotations, original = self._generate( + "task_env_strip_annotation_op.py", "task_env_strip_annotation", tmp_path + ) + # Baked runtime program is env- and authoring-free. + assert "from tangle_deploy" not in program + assert "@task" not in program + assert "TaskEnv" not in program + assert "UPI" not in program # incl. the `-> UPI` annotation (stripped) + # Plain runtime function survived (annotation stripped from signature). + assert "def task_env_strip_annotation(" in program + # Verbatim annotation untouched: it STILL carries env decl + annotation. + assert annotations["python_original_code"] == original + assert "UPI = TaskEnv(" in annotations["python_original_code"] + assert "-> UPI:" in annotations["python_original_code"] + # Proof it actually runs (would be NameError: UPI/TaskEnv if it leaked, + # or generation would have wrongly fail-fasted before N1). + self._run_baked(program, tmp_path, "annotation") + + def test_nested_env_import_fails_loud_at_generator_layer(self, tmp_path): + # FIX N2 (§3.5): an env import nested in an `if`/`try` block cannot be + # safely stripped (module-level removal only touches the module body), + # so it would silently leak into the baked program. The generator must + # re-raise AuthoringStripError loudly with actionable guidance and write + # no output, exactly like the mixed-import / body-ref violations. + output_file = tmp_path / "nested.yaml" + with pytest.raises(AuthoringStripError) as excinfo: + generate_component_yaml( + file_path=_PIPELINE_FIXTURES / "task_env_strip_nested_import_op.py", + output_path=output_file, + container_image="python:3.12", + function_name="task_env_strip_nested_import", + mode="inline", + ) + msg = str(excinfo.value) + assert "UPI" in msg + assert "nested" in msg + assert "module-level" in msg + assert "top-level import" in msg + # Hard failure: nothing was written. + assert not output_file.exists() + + +# ============================================================================ +# NamedTuple return type tests +# ============================================================================ + + +class TestNamedTupleReturnType: + """Tests for NamedTuple return type -> output declaration generation.""" + + def test_resolve_return_type_namedtuple_str(self): + """Functional-style NamedTuple with str field produces return_output.""" + from typing import NamedTuple + + def my_func(x: str) -> NamedTuple("Outputs", created_table=str): + return ("table",) + + params, single = _resolve_return_type(my_func) + assert len(params) == 1 + assert params[0].name == "created_table" + assert params[0].tangle_type == "String" + assert params[0].kind == "return_output" + assert single is False + + def test_resolve_return_type_namedtuple_multiple_fields(self): + """NamedTuple with multiple fields of different types.""" + from typing import NamedTuple + + def my_func(x: str) -> NamedTuple("Outputs", sql_query=str, row_count=int, metrics=dict): + return ("q", 10, {}) + + params, single = _resolve_return_type(my_func) + assert len(params) == 3 + assert params[0].name == "sql_query" + assert params[0].tangle_type == "String" + assert params[1].name == "row_count" + assert params[1].tangle_type == "Integer" + assert params[2].name == "metrics" + assert params[2].tangle_type == "JsonObject" + assert single is False + + def test_resolve_return_type_no_return(self): + """Function without return annotation produces empty list.""" + + def my_func(x: str): + pass + + params, single = _resolve_return_type(my_func) + assert params == [] + assert single is False + + def test_resolve_return_type_plain_type(self): + """Function returning a plain type produces single Output param.""" + + def my_func(x: str) -> str: + return "" + + params, single = _resolve_return_type(my_func) + assert len(params) == 1 + assert params[0].name == "Output" + assert params[0].tangle_type == "String" + assert params[0].kind == "return_output" + assert single is True + + def test_extract_interface_with_namedtuple(self): + """extract_interface populates return_params for NamedTuple returns.""" + from typing import NamedTuple + + def create_table( + project: str, + table_name: str, + ) -> NamedTuple("Outputs", created_table=str): + """Create a table. + + Args: + project: The cloud project. + table_name: The table name. + + Returns: + created_table: The full table name. + """ + return (f"{project}.{table_name}",) + + spec = extract_interface(create_table, {}) + assert len(spec.return_params) == 1 + assert spec.return_params[0].name == "created_table" + assert spec.return_params[0].tangle_type == "String" + assert spec.return_params[0].description == "The full table name." + + def test_build_args_section_with_return_outputs(self): + """Args section includes ----output-paths for NamedTuple return outputs.""" + spec = FunctionSpec( + name="my_func", + component_name="My func", + description="test", + params=[ + ParamInfo( + name="x", yaml_name="x", python_type="str", tangle_type="String", kind="input", deserializer="str" + ), + ], + return_params=[ + ParamInfo( + name="result", + yaml_name="result", + python_type="str", + tangle_type="String", + kind="return_output", + deserializer="_serialize_str", + ), + ], + ) + + args = _build_args_section(spec) + assert "----output-paths" in args + assert {"outputPath": "result"} in args + + def test_build_argparse_code_with_return_outputs(self): + """Argparse code includes output-paths handling and serialization.""" + spec = FunctionSpec( + name="my_func", + component_name="My func", + description="test", + params=[ + ParamInfo( + name="x", yaml_name="x", python_type="str", tangle_type="String", kind="input", deserializer="str" + ), + ], + return_params=[ + ParamInfo( + name="result", + yaml_name="result", + python_type="str", + tangle_type="String", + kind="return_output", + deserializer="_serialize_str", + ), + ], + ) + + code = _build_argparse_code(spec) + assert '"----output-paths"' in code + assert '_output_files = _parsed_args.pop("_output_paths", [])' in code + assert "_output_serializers" in code + assert "_serialize_str" in code + + def test_end_to_end_namedtuple_generation(self, tmp_path): + """Full end-to-end: Python file with NamedTuple return -> YAML with outputs.""" + py_file = tmp_path / "create_table.py" + py_file.write_text(textwrap.dedent("""\ + from typing import NamedTuple + + def create_table( + project: str, + table_name: str, + ) -> NamedTuple("Outputs", created_table=str): + \"\"\"Create a data warehouse table. + + Args: + project: The cloud project. + table_name: The table name. + + Returns: + created_table: The full table name. + \"\"\" + return (f"{project}.{table_name}",) + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="create_table", + dependencies_from=toml_file, + mode="inline", + ) + + assert success is True + assert output_file.exists() + + with open(output_file) as f: + component = yaml.safe_load(f) + + assert len(component["inputs"]) == 2 + assert component["inputs"][0]["name"] == "project" + assert component["inputs"][1]["name"] == "table_name" + + assert "outputs" in component + assert len(component["outputs"]) == 1 + assert component["outputs"][0]["name"] == "created_table" + assert component["outputs"][0]["type"] == "String" + + args = component["implementation"]["container"]["args"] + assert "----output-paths" in args + has_output_path = any(isinstance(a, dict) and a.get("outputPath") == "created_table" for a in args) + assert has_output_path + + python_source = component["implementation"]["container"]["command"][-1] + assert "_output_serializers" in python_source + assert "_serialize_str" in python_source + + def test_end_to_end_single_return_generation(self, tmp_path): + """Full end-to-end: Python file with -> str return -> YAML with single Output.""" + py_file = tmp_path / "greet.py" + py_file.write_text(textwrap.dedent("""\ + def greet(name: str) -> str: + \"\"\"Greet someone. + + Args: + name: The person's name. + \"\"\" + return f"Hello, {name}!" + """)) + + toml_file = tmp_path / "pyproject.toml" + toml_file.write_text('[project]\nname = "test"\ndependencies = []\n') + + output_file = tmp_path / "output.yaml" + + success = generate_component_yaml( + file_path=py_file, + output_path=output_file, + container_image="python:3.12", + function_name="greet", + dependencies_from=toml_file, + mode="inline", + ) + + assert success is True + + with open(output_file) as f: + component = yaml.safe_load(f) + + assert "outputs" in component + assert len(component["outputs"]) == 1 + assert component["outputs"][0]["name"] == "Output" + assert component["outputs"][0]["type"] == "String" + + python_source = component["implementation"]["container"]["command"][-1] + assert "_outputs = [_outputs]" in python_source + assert "_serialize_str" in python_source + + args = component["implementation"]["container"]["args"] + assert "----output-paths" in args + assert {"outputPath": "Output"} in args diff --git a/tests/test_component_generator.py b/tests/test_component_generator.py new file mode 100644 index 0000000..73ac3ea --- /dev/null +++ b/tests/test_component_generator.py @@ -0,0 +1,303 @@ +"""Tests for Python-function component generation helpers and CLI wiring.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from tangle_cli import cli +from tangle_cli.component_from_func import AuthoringStripError, generate_component_yaml +from tangle_cli.component_generator import ( + DEFAULT_CONTAINER_IMAGE, + determine_output_path, + find_dependencies_file, + regenerate_yaml, +) + + +DUMMY_PYTHON_COMPONENT = '''#!/usr/bin/env python3 +"""Module docstring.""" + +def test_component(input_data: str, threshold: float = 0.5) -> dict: + """ + Processes and validates input data. + + Args: + input_data: The data to process + threshold: Processing threshold + + Returns: + Processing results as a dictionary + + Metadata: + Name: Data Processor + Version: 2.1.0 + updated_at: 2024-11-23T10:00:00Z + """ + return { + "processed": input_data.upper(), + "threshold_met": len(input_data) > threshold + } + +if __name__ == "__main__": + print(test_component("test", 0.3)) +''' + + +SNAPSHOTS_DIR = Path(__file__).parent / "snapshots" / "component_generator" + + +def run_app(app, args): + with pytest.raises(SystemExit) as exc_info: + app(args) + assert exc_info.value.code == 0 + + +def _assert_yaml_matches(actual_path: Path, expected_path: Path) -> None: + assert actual_path.exists(), f"Generated file not found: {actual_path}" + with actual_path.open(encoding="utf-8") as f: + actual = yaml.safe_load(f) + with expected_path.open(encoding="utf-8") as f: + expected = yaml.safe_load(f) + assert actual == expected + + +class TestDependenciesDiscovery: + def test_find_component_specific_toml(self, tmp_path: Path): + py_file = tmp_path / "my_component.py" + py_file.write_text("def main(): pass", encoding="utf-8") + toml_file = tmp_path / "my-component.toml" + toml_file.write_text("[project]\nname = 'test'", encoding="utf-8") + + assert find_dependencies_file(py_file) == toml_file + + def test_find_pyproject_in_parent(self, tmp_path: Path): + subdir = tmp_path / "components" + subdir.mkdir() + py_file = subdir / "component.py" + py_file.write_text("def main(): pass", encoding="utf-8") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[project]\nname = 'test'", encoding="utf-8") + + assert find_dependencies_file(py_file) == pyproject + + def test_no_dependencies_file(self, tmp_path: Path): + py_file = tmp_path / "component.py" + py_file.write_text("def main(): pass", encoding="utf-8") + + assert find_dependencies_file(py_file) is None + + +class TestOutputPathDetermination: + def test_output_same_directory(self): + assert determine_output_path(Path("/project/components/my_component.py")) == Path( + "/project/components/my-component.yaml" + ) + + def test_output_sources_directory_not_special_cased(self): + assert determine_output_path(Path("/project/components/sources/my_component.py")) == Path( + "/project/components/sources/my-component.yaml" + ) + + def test_output_custom_file(self): + output_path = Path("/output/custom.yaml") + assert determine_output_path(Path("/project/component.py"), output_path) == output_path + + def test_output_custom_directory_no_extension(self): + assert determine_output_path(Path("/project/component.py"), Path("/output/dir")) == Path( + "/output/dir/component.yaml" + ) + + def test_output_custom_directory_explicit(self): + assert determine_output_path( + Path("/project/component.py"), Path("/output/dir"), output_is_dir=True + ) == Path("/output/dir/component.yaml") + + +@pytest.mark.parametrize("use_cli", [False, True]) +def test_complete_generation_flow(monkeypatch, tmp_path: Path, use_cli: bool): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + py_file = tmp_path / "test_component.py" + py_file.write_text(DUMMY_PYTHON_COMPONENT, encoding="utf-8") + yaml_file = tmp_path / "test-component.yaml" + + if use_cli: + app = cli.build_app() + run_app( + app, + [ + "sdk", + "components", + "generate", + "from-python", + str(py_file), + "--image", + "test-image:latest", + ], + ) + else: + assert regenerate_yaml(py_file, image="test-image:latest") is True + + _assert_yaml_matches(yaml_file, SNAPSHOTS_DIR / "complete_generation.expected.yaml") + + +def test_regenerate_yaml_default_image_is_pinned(monkeypatch, tmp_path: Path): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + py_file = tmp_path / "test_component.py" + py_file.write_text(DUMMY_PYTHON_COMPONENT, encoding="utf-8") + + assert regenerate_yaml(py_file) is True + + generated = yaml.safe_load((tmp_path / "test-component.yaml").read_text(encoding="utf-8")) + assert generated["implementation"]["container"]["image"] == DEFAULT_CONTAINER_IMAGE + assert "@sha256:" in generated["implementation"]["container"]["image"] + + +def test_generate_component_yaml_default_emits_generation_annotations_and_oss_paths( + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + src_dir = tmp_path / "src" + out_dir = tmp_path / "generated" + src_dir.mkdir() + out_dir.mkdir() + py_file = src_dir / "component.py" + py_file.write_text(DUMMY_PYTHON_COMPONENT, encoding="utf-8") + yaml_file = out_dir / "component.yaml" + + assert generate_component_yaml(py_file, yaml_file, "python:3.12", function_name="test_component") is True + + generated = yaml.safe_load(yaml_file.read_text(encoding="utf-8")) + annotations = generated["metadata"]["annotations"] + assert annotations["python_original_code_path"] == "src/component.py" + assert annotations["component_yaml_path"] == "generated/component.yaml" + assert annotations["tangle_cli_generation_function_name"] == "test_component" + assert annotations["tangle_cli_generation_mode"] == "inline" + + +def test_generate_component_yaml_can_disable_generation_annotations_and_use_td_legacy_paths( + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + src_dir = tmp_path / "src" + out_dir = tmp_path / "generated" + src_dir.mkdir() + out_dir.mkdir() + py_file = src_dir / "component.py" + py_file.write_text(DUMMY_PYTHON_COMPONENT, encoding="utf-8") + yaml_file = out_dir / "component.yaml" + + assert generate_component_yaml( + py_file, + yaml_file, + "python:3.12", + function_name="test_component", + emit_generation_annotations=False, + path_annotation_mode="td_legacy", + ) is True + + generated = yaml.safe_load(yaml_file.read_text(encoding="utf-8")) + annotations = generated["metadata"]["annotations"] + assert annotations["python_original_code_path"] == "component.py" + assert annotations["component_yaml_path"] == "component.yaml" + assert "tangle_cli_generation_function_name" not in annotations + assert "tangle_cli_generation_mode" not in annotations + assert "tangle_cli_generation_dependencies_from" not in annotations + assert "tangle_cli_generation_resolve_root" not in annotations + + +def test_from_python_function_alias_and_config(monkeypatch, tmp_path: Path): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + py_file = tmp_path / "my_component.py" + py_file.write_text(''' +def my_component(name: str) -> str: + """Echo a name. + + Metadata: + version: 1.0 + """ + return name +''', encoding="utf-8") + config = tmp_path / "config.yaml" + config.write_text( + f"python_file: {py_file}\n" + "image: python:3.12\n" + "name: Configured Component\n", + encoding="utf-8", + ) + + app = cli.build_app() + run_app(app, ["sdk", "components", "generate", "from-python-function", "--config", str(config)]) + + generated = yaml.safe_load((tmp_path / "my-component.yaml").read_text(encoding="utf-8")) + assert generated["name"] == "Configured Component" + assert generated["metadata"]["annotations"]["version"] == "1.0" + + +def test_regenerate_yaml_reraises_authoring_strip_errors(monkeypatch, tmp_path: Path): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + py_file = tmp_path / "bad_authoring.py" + py_file.write_text( + '''from tangle_deploy.python_pipeline import TaskEnv, task + +UPI = TaskEnv(image="python:3.12") + +@task(env=UPI) +def bad_authoring() -> str: + return UPI +''', + encoding="utf-8", + ) + + with pytest.raises(AuthoringStripError): + regenerate_yaml(py_file, image="python:3.12", function_name="bad_authoring") + + assert not (tmp_path / "bad-authoring.yaml").exists() + + +def test_bundle_mode_with_local_imports(monkeypatch, tmp_path: Path): + monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None) + helpers_dir = tmp_path / "helpers" + helpers_dir.mkdir() + (helpers_dir / "__init__.py").write_text("", encoding="utf-8") + (helpers_dir / "utils.py").write_text("def clean(text):\n return text.strip().lower()\n", encoding="utf-8") + py_file = tmp_path / "my_component.py" + py_file.write_text('''from helpers.utils import clean + +def my_component(name: str) -> str: + """Clean a name. + + Metadata: + version: 1.0 + """ + return clean(name) +''', encoding="utf-8") + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\ndependencies = []\n', encoding="utf-8") + + assert regenerate_yaml(py_file, image="python:3.12", function_name="my_component", mode="bundle") is True + + generated = yaml.safe_load((tmp_path / "my-component.yaml").read_text(encoding="utf-8")) + program = generated["implementation"]["container"]["command"][-1] + assert generated["name"] == "My component" + assert "_EMBEDDED_MODULES" in program + assert "helpers.utils" in program + + +def test_bump_version_cli_uses_config(tmp_path: Path): + yaml_file = tmp_path / "component.yaml" + yaml_file.write_text( + 'name: demo\nmetadata:\n annotations:\n version: "1.2"\n', + encoding="utf-8", + ) + config = tmp_path / "bump.yaml" + config.write_text(f"yaml_file: {yaml_file}\nset_version: '2.0'\n", encoding="utf-8") + + app = cli.build_app() + run_app(app, ["sdk", "components", "bump-version", "--config", str(config)]) + + updated = yaml.safe_load(yaml_file.read_text(encoding="utf-8")) + assert updated["metadata"]["annotations"]["version"] == "2.0" diff --git a/tests/test_component_inspector.py b/tests/test_component_inspector.py new file mode 100644 index 0000000..7d5c068 --- /dev/null +++ b/tests/test_component_inspector.py @@ -0,0 +1,270 @@ +"""Tests for static-client-backed component inspection helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from tangle_cli import component_inspector +from tangle_cli.component_inspector import ComponentInspector +from tangle_cli.models import ComponentInfo, ComponentSpec + + +@dataclass +class FakeResponse: + text: str + + def raise_for_status(self) -> None: + return None + + +@pytest.fixture(autouse=True) +def reset_component_library_cache(): + component_inspector._component_libraries_by_client.clear() + yield + component_inspector._component_libraries_by_client.clear() + + +class FakeClient: + base_url = "https://tangle.example.com" + + def __init__(self): + self.component = { + "digest": "abc123", + "spec": { + "name": "demo", + "description": "Demo component", + "metadata": {"annotations": {"version": "1.2.3"}}, + "implementation": {"container": {"image": "python:3.12-slim"}}, + }, + } + + def get_component_spec(self, digest: str) -> ComponentSpec | None: + if digest != "abc123": + return None + return ComponentSpec.from_dict(self.component) + + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + if params.get("digest") == "abc123" or params.get("name_substring") == "demo": + return [ + ComponentInfo( + name="demo", + digest="abc123", + version="1.2.3", + published_by="user@example.com", + description="Demo component", + ) + ] + if params.get("digest") == "old": + return [ + ComponentInfo( + name="demo", + digest="old", + version="1.0.0", + deprecated=True, + superseded_by="abc123", + ) + ] + return [] + + +class TestTransparencyCheck: + def test_standard_public_base_image_is_transparent(self): + spec = ComponentSpec.from_dict({ + "spec": { + "name": "demo", + "implementation": {"container": {"image": "python:3.12-slim"}}, + }, + }) + + transparent, reason = ComponentInspector.transparency_check(spec) + + assert transparent is True + assert "standard public base image" in reason + + def test_unknown_container_is_opaque(self): + spec = ComponentSpec.from_dict({ + "spec": { + "name": "demo", + "implementation": {"container": {"image": "registry.example.com/private/demo:latest"}}, + }, + }) + + transparent, reason = ComponentInspector.transparency_check(spec) + + assert transparent is False + assert "no inline source" in reason + + +class TestComponentLibrary: + def test_standard_library_does_not_fetch_cross_origin_component_urls(self): + class LibraryClient: + base_url = "https://tangle.example.com" + + def __init__(self): + self.paths: list[str] = [] + + def request_path(self, path: str): + self.paths.append(path) + if path == "/component_library.yaml": + return FakeResponse( + "folders:\n" + " - name: demo\n" + " components:\n" + " - url: http://127.0.0.1/internal.yaml\n" + ) + raise AssertionError(f"unexpected fetch: {path}") + + client = LibraryClient() + + library = ComponentInspector(client=client).get_standard_library() + + assert client.paths == ["/component_library.yaml"] + assert library["folders"][0]["components"][0] == { + "url": "http://127.0.0.1/internal.yaml", + "spec": None, + } + + def test_standard_library_fetches_relative_component_urls_through_client(self): + class LibraryClient: + base_url = "https://tangle.example.com" + + def __init__(self): + self.paths: list[str] = [] + + def request_path(self, path: str): + self.paths.append(path) + if path == "/component_library.yaml": + return FakeResponse( + "folders:\n" + " - name: demo\n" + " components:\n" + " - url: components/demo.yaml\n" + ) + if path == "/components/demo.yaml": + return FakeResponse("name: demo\ndescription: Demo from library\n") + raise AssertionError(f"unexpected fetch: {path}") + + client = LibraryClient() + + library = ComponentInspector(client=client).get_standard_library() + + assert client.paths == ["/component_library.yaml", "/components/demo.yaml"] + assert library["folders"][0]["components"][0]["spec"]["name"] == "demo" + + def test_component_library_cache_is_scoped_per_client(self): + class LibraryFallbackClient: + base_url = "https://tangle.example.com" + + def __init__(self, component_name: str | None): + self.component_name = component_name + self.paths: list[str] = [] + + def get_component_spec(self, digest: str) -> ComponentSpec | None: + return None + + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + return [] + + def request_path(self, path: str): + self.paths.append(path) + if path != "/component_library.yaml": + raise AssertionError(f"unexpected fetch: {path}") + if self.component_name is None: + return FakeResponse("folders: []\n") + return FakeResponse( + "folders:\n" + " - name: demo\n" + " components:\n" + " - spec:\n" + f" name: {self.component_name}\n" + " description: Demo from library\n" + ) + + first_client = LibraryFallbackClient("private-a") + second_client = LibraryFallbackClient(None) + + first_result = ComponentInspector(client=first_client).inspect_by_name("private-a") + second_result = ComponentInspector(client=second_client).inspect_by_name("private-a") + + assert first_result["status"] == "success" + assert second_result["status"] == "not_found" + assert first_client.paths == ["/component_library.yaml"] + assert second_client.paths == ["/component_library.yaml"] + + +class TestInspectComponents: + def test_inspect_by_digest_merges_spec_and_publication_metadata(self): + result = ComponentInspector(client=FakeClient()).inspect_by_digest("abc123") + + assert result["status"] == "success" + assert result["name"] == "demo" + assert result["digest"] == "abc123" + assert result["version"] == "1.2.3" + assert result["transparent"] is True + assert "implementation" not in result["spec"] + + def test_inspect_by_digest_can_follow_deprecated_chain(self): + result = ComponentInspector(client=FakeClient()).inspect_by_digest("old", follow_deprecated=True) + + assert result["status"] == "success" + assert result["digest"] == "abc123" + + def test_inspect_by_digest_backfills_missing_published_version_from_spec(self): + class MissingPublishedVersionClient(FakeClient): + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + if params.get("digest") == "abc123": + return [ComponentInfo(name="demo", digest="abc123")] + return super().list_published_component_infos(**params) + + result = ComponentInspector(client=MissingPublishedVersionClient()).inspect_by_digest("abc123") + + assert result["status"] == "success" + assert result["version"] == "1.2.3" + + def test_inspect_by_name_backfills_missing_published_version_from_spec(self): + class MissingPublishedVersionClient(FakeClient): + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + if params.get("name_substring") == "demo": + return [ComponentInfo(name="demo", digest="abc123")] + return super().list_published_component_infos(**params) + + result = ComponentInspector(client=MissingPublishedVersionClient()).inspect_by_name("demo") + + assert result["status"] == "success" + assert result["versions"][0]["version"] == "1.2.3" + + def test_inspect_by_name_returns_matching_versions(self): + result = ComponentInspector(client=FakeClient()).inspect_by_name("demo") + + assert result["status"] == "success" + assert result["name"] == "demo" + assert result["version_count"] == 1 + assert result["versions"][0]["digest"] == "abc123" + + def test_search_components_returns_summary_rows(self): + result = ComponentInspector(client=FakeClient()).search_components(name="demo") + + assert result == { + "status": "success", + "query": "demo", + "count": 1, + "components": [{ + "name": "demo", + "digest": "abc123", + "version": "1.2.3", + "deprecated": False, + "description": "Demo component", + }], + } + + def test_search_components_handles_null_description(self): + class NullDescriptionClient(FakeClient): + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + return [ComponentInfo(name="demo", digest="abc123", description=None)] + + result = ComponentInspector(client=NullDescriptionClient()).search_components(name="demo") + + assert result["components"][0]["description"] == "" diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py new file mode 100644 index 0000000..8a35160 --- /dev/null +++ b/tests/test_component_publisher.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from tangle_api.generated.models import ComponentSpec + +from tangle_cli.component_publisher import ( + ComponentPublishContext, + ComponentPublisher, + ProcessingOutcome, + ProcessingResult, + deprecate_component, + deprecate_old_components, + perform_version_check, + publish_component_to_tangle, +) +from tangle_cli.logger import CaptureLogger, NullLogger + + +@dataclass +class User: + id: str + + +@dataclass +class ExistingComponent: + digest: str + name: str = "demo" + + +class FakeClient: + def __init__(self) -> None: + self.user: User | None = User("alice@example.com") + self.existing: list[ExistingComponent] = [] + self.component_versions: dict[str, str] = {} + self.publish_response: dict[str, Any] = {"digest": "sha256:new"} + self.users_me_calls = 0 + self.find_calls: list[dict[str, Any]] = [] + self.create_calls: list[dict[str, Any]] = [] + self.update_calls: list[dict[str, Any]] = [] + + def users_me(self) -> User | None: + self.users_me_calls += 1 + return self.user + + def find_existing_components(self, components: Any, **kwargs: Any) -> list[ExistingComponent]: + self.find_calls.append({"components": list(components), **kwargs}) + return self.existing + + def get_component_spec(self, digest: str) -> ComponentSpec: + version = self.component_versions[digest] + return ComponentSpec.from_yaml(f"name: demo\nmetadata:\n annotations:\n version: '{version}'\n") + + def published_components_create(self, **kwargs: Any) -> dict[str, Any]: + self.create_calls.append(kwargs) + return self.publish_response + + def published_components_update(self, **kwargs: Any) -> dict[str, Any]: + self.update_calls.append(kwargs) + return {"digest": kwargs["digest"], "deprecated": kwargs.get("deprecated")} + + +def write_component(path: Path, *, name: str = "demo", version: str | None = "1.0") -> Path: + annotations = {} if version is None else {"version": version} + path.write_text( + yaml.safe_dump( + { + "name": name, + "metadata": {"annotations": annotations}, + "implementation": {"container": {"image": "python:3.12"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + return path + + +def test_publish_file_read_error() -> None: + result = publish_component_to_tangle("/nonexistent/file.yaml", dry_run=True) + + assert result.outcome == ProcessingOutcome.ERROR + assert result.reason is not None and "Failed to read file" in result.reason + assert result.local_version is None + assert result.latest_version is None + + +def test_publish_no_version_in_yaml_skips(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml", version=None) + + result = publish_component_to_tangle(component_path, dry_run=True) + + assert result.outcome == ProcessingOutcome.SKIP + assert result.reason is not None and "Component version is required" in result.reason + + +def test_publish_yaml_parsing_error(tmp_path: Path) -> None: + component_path = tmp_path / "component.yaml" + component_path.write_text("invalid: yaml: content:", encoding="utf-8") + + result = publish_component_to_tangle(component_path, dry_run=True) + + assert result.outcome == ProcessingOutcome.ERROR + assert result.reason is not None + + +def test_client_factory_lazily_creates_downstream_client(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml") + client = FakeClient() + calls = [] + + def client_factory() -> FakeClient: + calls.append("created") + return client + + publisher = ComponentPublisher(client_factory=client_factory) + + assert calls == [] + result = publisher.publish_component(component_path) + + assert result.outcome == ProcessingOutcome.SUCCESS + assert calls == ["created"] + assert client.create_calls + + +def test_client_factory_is_not_called_for_dry_run(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml") + calls = [] + + def client_factory() -> FakeClient: + calls.append("created") + return FakeClient() + + result = publish_component_to_tangle(component_path, dry_run=True, client_factory=client_factory) + + assert result.outcome == ProcessingOutcome.SUCCESS + assert calls == [] + + +def test_client_creation_failure(monkeypatch, tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml") + + def fake_get_client(self: ComponentPublisher) -> None: + return None + + monkeypatch.setattr(ComponentPublisher, "_get_client", fake_get_client) + result = ComponentPublisher(dry_run=False).publish_component(component_path) + + assert result.outcome == ProcessingOutcome.ERROR + assert result.reason == "Failed to create TangleApiClient" + + +def test_dry_run_success_does_not_call_api(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml", name="dry") + client = FakeClient() + + result = publish_component_to_tangle( + component_path, + dry_run=True, + client=client, + git_remote_sha="abc123", + git_remote_branch="main", + ) + + assert result.outcome == ProcessingOutcome.SUCCESS + assert result.reason is not None and "Dry-run: would publish" in result.reason + assert result.local_version == "1.0" + assert client.create_calls == [] + assert result.spec.annotations["git_remote_sha"] == "abc123" + assert result.spec.annotations["git_remote_branch"] == "main" + + +def test_version_check_filters_by_current_author() -> None: + spec = ComponentSpec.from_yaml("name: demo\nmetadata:\n annotations:\n version: '1.0'\n") + client = FakeClient() + + result = perform_version_check(spec=spec, dry_run=False, client=client) + + assert result.outcome == ProcessingOutcome.PROCEED + assert client.find_calls == [ + { + "components": ["demo", "[Official] demo"], + "verbose": False, + "published_by": "alice@example.com", + } + ] + + +def test_version_check_fails_closed_without_current_user() -> None: + spec = ComponentSpec.from_yaml("name: demo\nmetadata:\n annotations:\n version: '1.0'\n") + client = FakeClient() + client.user = None + + result = perform_version_check(spec=spec, dry_run=False, client=client) + + assert result.outcome == ProcessingOutcome.ERROR + assert result.reason == "Cannot determine current user for author filtering" + assert client.find_calls == [] + + +def test_version_check_skips_unchanged_owner_scoped_version() -> None: + spec = ComponentSpec.from_yaml("name: demo\nmetadata:\n annotations:\n version: '1.0'\n") + client = FakeClient() + client.existing = [ExistingComponent("sha256:old")] + client.component_versions = {"sha256:old": "1.0"} + + result = perform_version_check(spec=spec, dry_run=False, client=client) + + assert result.outcome == ProcessingOutcome.SKIP + assert result.latest_version == "1.0" + assert "unchanged" in (result.reason or "") + + +def test_version_check_progress_uses_logger_not_tangle_verbose(monkeypatch, capsys) -> None: + spec = ComponentSpec.from_yaml("name: demo\nmetadata:\n annotations:\n version: '1.0'\n") + client = FakeClient() + client.existing = [ExistingComponent("sha256:old")] + client.component_versions = {"sha256:old": "1.0"} + + monkeypatch.setenv("TANGLE_VERBOSE", "1") + result = perform_version_check( + spec=spec, + dry_run=False, + client=client, + logger=NullLogger(), + ) + + assert result.outcome == ProcessingOutcome.SKIP + assert capsys.readouterr().err == "" + + monkeypatch.setenv("TANGLE_VERBOSE", "0") + capture = CaptureLogger() + result = perform_version_check( + spec=spec, + dry_run=False, + client=client, + logger=capture, + ) + + assert result.outcome == ProcessingOutcome.SKIP + logs = capture.get_logs() or "" + assert "Local version: 1.0" in logs + assert "Remote version: 1.0" in logs + assert "Skipping: Version 1.0 unchanged" in logs + + +def test_successful_publish_deprecates_owner_scoped_old_versions(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml", name="demo", version="1.1") + client = FakeClient() + client.existing = [ExistingComponent("sha256:old")] + client.component_versions = {"sha256:old": "1.0"} + client.publish_response = {"digest": "sha256:newer"} + + result = publish_component_to_tangle( + component_path, + client=client, + image="python:3.13", + name="Published Name", + description="Published description", + annotations={"owner": "oss"}, + ) + + assert result.outcome == ProcessingOutcome.SUCCESS + assert result.digest == "sha256:newer" + assert client.create_calls and client.create_calls[0]["name"] == "Published Name" + payload = yaml.safe_load(client.create_calls[0]["text"]) + assert payload["name"] == "Published Name" + assert payload["description"] == "Published description" + assert payload["implementation"]["container"]["image"] == "python:3.13" + assert payload["metadata"]["annotations"]["owner"] == "oss" + assert "published_at" in payload["metadata"]["annotations"] + assert client.update_calls == [ + {"digest": "sha256:old", "deprecated": True, "superseded_by": "sha256:newer"}, + ] + + +def test_publish_error_when_no_digest_returned(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml") + client = FakeClient() + client.publish_response = {"name": "demo"} + + result = publish_component_to_tangle(component_path, client=client) + + assert result.outcome == ProcessingOutcome.ERROR + assert result.reason == "Component published but no digest returned" + + +def test_deprecate_old_components_skips_new_digest() -> None: + client = FakeClient() + + count = deprecate_old_components( + [ExistingComponent("sha256:old"), ExistingComponent("sha256:new")], + "sha256:new", + client=client, + ) + + assert count == 1 + assert client.update_calls == [ + {"digest": "sha256:old", "deprecated": True, "superseded_by": "sha256:new"} + ] + + +def test_deprecate_component_calls_generated_update() -> None: + client = FakeClient() + + result = deprecate_component(client, "sha256:old", superseded_by="sha256:new") + + assert result["success"] is True + assert client.update_calls == [ + {"digest": "sha256:old", "deprecated": True, "superseded_by": "sha256:new"} + ] + + +class RecordingHook: + def __init__(self) -> None: + self.events: list[tuple[str, Any]] = [] + + def before_batch(self, components_config: list[dict[str, Any]]) -> None: + self.events.append(("before", len(components_config))) + + def after_component(self, component_path: str, result: ProcessingResult) -> None: + self.events.append(("component", component_path, result.outcome.value)) + + def after_batch(self, results: list[tuple[str, ProcessingResult]]) -> None: + self.events.append(("after", len(results))) + + +class ContextHook: + def __init__(self) -> None: + self.contexts: list[ComponentPublishContext] = [] + + def before_batch(self, components_config: list[dict[str, Any]], *, context: ComponentPublishContext) -> None: + self.contexts.append(context) + + def after_component( + self, + component_path: str, + result: ProcessingResult, + *, + context: ComponentPublishContext, + ) -> None: + self.contexts.append(context) + + def after_batch( + self, + results: list[tuple[str, ProcessingResult]], + *, + context: ComponentPublishContext, + ) -> None: + self.contexts.append(context) + + +class KwargsContextHook: + def __init__(self) -> None: + self.contexts: list[ComponentPublishContext] = [] + + def after_batch(self, results: list[tuple[str, ProcessingResult]], **kwargs: Any) -> None: + self.contexts.append(kwargs["context"]) + + +def test_publish_components_passes_structured_context_to_context_aware_hooks(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml", name="demo", version="1.0") + hook = ContextHook() + kwargs_hook = KwargsContextHook() + publisher = ComponentPublisher( + dry_run=True, + hooks=[hook, kwargs_hook], + git_remote_sha="sha", + git_remote_branch="main", + git_remote_url="https://github.com/TangleML/example-pipelines", + git_repo="TangleML/example-pipelines", + git_root=tmp_path, + published_by="alice@example.com", + ) + + exit_code = publisher.publish_components([{"component_path": component_path, "name": "Demo"}]) + + assert exit_code == 0 + before_context, component_context, after_context = hook.contexts + assert before_context.git_remote_sha == "sha" + assert before_context.git_remote_branch == "main" + assert before_context.git_remote_url == "https://github.com/TangleML/example-pipelines" + assert before_context.git_repo == "TangleML/example-pipelines" + assert before_context.git_root == str(tmp_path) + assert before_context.published_by == "alice@example.com" + assert before_context.batch_config == [{"component_path": component_path, "name": "Demo"}] + assert component_context.component_path == str(component_path) + assert component_context.component_config == {"component_path": component_path, "name": "Demo"} + assert component_context.result is publisher.results[0][1] + assert component_context.results == tuple(publisher.results) + assert after_context.results == tuple(publisher.results) + assert kwargs_hook.contexts == [after_context] + + +def test_publish_components_batches_configs_and_runs_hooks(tmp_path: Path) -> None: + first = write_component(tmp_path / "one.yaml", name="one", version="1.0") + second = write_component(tmp_path / "two.yaml", name="two", version="2.0") + client = FakeClient() + hook = RecordingHook() + publisher = ComponentPublisher(dry_run=True, client=client, hooks=[hook]) + + exit_code = publisher.publish_components( + [ + {"component_path": first, "image": "python:3.12"}, + {"component_path": second, "name": "Two"}, + ] + ) + + assert exit_code == 0 + assert len(publisher.results) == 2 + assert [result.outcome for _, result in publisher.results] == [ + ProcessingOutcome.SUCCESS, + ProcessingOutcome.SUCCESS, + ] + assert hook.events == [ + ("before", 2), + ("component", str(first), "success"), + ("component", str(second), "success"), + ("after", 2), + ] + + +def test_publish_components_returns_nonzero_for_errors(tmp_path: Path) -> None: + component_path = write_component(tmp_path / "component.yaml") + publisher = ComponentPublisher(dry_run=True) + + exit_code = publisher.publish_components([ + {"component_path": component_path}, + {}, + ]) + + assert exit_code == 1 + assert [result.outcome for _, result in publisher.results] == [ + ProcessingOutcome.SUCCESS, + ProcessingOutcome.ERROR, + ] diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py new file mode 100644 index 0000000..1dc4708 --- /dev/null +++ b/tests/test_components_cli.py @@ -0,0 +1,453 @@ +import builtins +import json +import sys +from pathlib import Path +from typing import Any +from unittest.mock import ANY + +import pytest +import yaml + +from tangle_cli import cli, published_components_cli +from tangle_cli.component_publisher import ProcessingOutcome, ProcessingResult + + +def run_app(app, args: list[str]) -> None: + try: + app(args) + except SystemExit as exc: + if exc.code not in (0, None): + raise + + +def _write_component(path: Path, *, name: str = "demo", version: str = "1.0") -> Path: + path.write_text( + yaml.safe_dump( + { + "name": name, + "metadata": {"annotations": {"version": version}}, + "implementation": {"container": {"image": "python:3.12"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + return path + + +class FakePublisher: + instances: list["FakePublisher"] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.publish_calls: list[dict[str, Any]] = [] + FakePublisher.instances.append(self) + + def publish_component(self, component_path: Path, **kwargs: Any) -> ProcessingResult: + self.publish_calls.append({"component_path": component_path, **kwargs}) + return ProcessingResult( + outcome=ProcessingOutcome.SUCCESS, + local_version="1.0", + latest_version=None, + reason=f"Dry-run: would publish {kwargs.get('name') or 'component'}", + ) + + +def test_published_components_publish_cli_wiring_and_config_precedence(monkeypatch, tmp_path: Path, capsys): + component_path = _write_component(tmp_path / "component.yaml", name="Config Name") + config = tmp_path / "publish.yaml" + config.write_text( + f"component_path: {component_path}\n" + "dry_run: true\n" + "name: Config Name\n" + "annotations:\n" + " from_config: yes\n", + encoding="utf-8", + ) + + def fake_client_from_options(**kwargs: Any) -> object: + raise AssertionError("dry-run publish must not create an API client") + + FakePublisher.instances = [] + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) + + app = cli.build_app() + run_app( + app, + ["sdk", "published-components", "publish", "--config", str(config), "--name", "CLI Name"], + ) + + result = json.loads(capsys.readouterr().out) + assert result["status"] == "success" + assert result["components_count"] == 1 + assert FakePublisher.instances[0].kwargs == { + "dry_run": True, + "git_remote_sha": None, + "git_remote_branch": None, + "git_remote_url": None, + "git_root": None, + "published_by": None, + "client": None, + "logger": ANY, + } + assert FakePublisher.instances[0].publish_calls == [ + { + "component_path": component_path, + "image": None, + "name": "CLI Name", + "description": None, + "annotations": {"from_config": True}, + } + ] + + +def test_published_components_publish_config_base_url_suppresses_env_credentials(monkeypatch, tmp_path: Path, capsys): + component_path = _write_component(tmp_path / "component.yaml", name="Config Name") + config = tmp_path / "publish.yaml" + config.write_text( + f"component_path: {component_path}\n" + "base_url: https://config.example\n" + "token: config-token\n" + "auth_header: Bearer config-auth\n" + "header:\n" + " - 'X-Config: yes'\n", + encoding="utf-8", + ) + fake_client = object() + client_calls: list[dict[str, Any]] = [] + FakePublisher.instances = [] + + def fake_client_from_options(**kwargs: Any) -> object: + client_calls.append(kwargs) + return fake_client + + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) + + app = cli.build_app() + run_app(app, ["sdk", "published-components", "publish", "--config", str(config)]) + + result = json.loads(capsys.readouterr().out) + assert result["status"] == "success" + assert client_calls == [ + { + "base_url": "https://config.example", + "token": "config-token", + "auth_header": "Bearer config-auth", + "header": ["X-Config: yes"], + "include_env_credentials": False, + "command_name": "published-component commands", + } + ] + assert FakePublisher.instances[0].kwargs["client"] is fake_client + + +def test_published_components_publish_cli_base_url_keeps_env_credentials(monkeypatch, tmp_path: Path, capsys): + component_path = _write_component(tmp_path / "component.yaml", name="Config Name") + config = tmp_path / "publish.yaml" + config.write_text( + f"component_path: {component_path}\n" + "base_url: https://config.example\n", + encoding="utf-8", + ) + client_calls: list[dict[str, Any]] = [] + FakePublisher.instances = [] + + def fake_client_from_options(**kwargs: Any) -> object: + client_calls.append(kwargs) + return object() + + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) + + app = cli.build_app() + run_app( + app, + [ + "sdk", + "published-components", + "publish", + "--config", + str(config), + "--base-url", + "https://cli.example", + ], + ) + + json.loads(capsys.readouterr().out) + assert client_calls[-1]["base_url"] == "https://cli.example" + assert client_calls[-1]["include_env_credentials"] is True + + +def test_published_components_publish_config_array_is_batch_interface(monkeypatch, tmp_path: Path, capsys): + first = _write_component(tmp_path / "one.yaml", name="One") + second = _write_component(tmp_path / "two.yaml", name="Two") + config = tmp_path / "publish-many.yaml" + config.write_text( + "_defaults:\n" + " dry_run: true\n" + " image: python:3.12\n" + "configs:\n" + f" - component_path: {first}\n" + " name: One Config\n" + f" - component_path: {second}\n" + " name: Two Config\n", + encoding="utf-8", + ) + + FakePublisher.instances = [] + monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) + + app = cli.build_app() + run_app(app, ["sdk", "published-components", "publish", "--config", str(config)]) + + result = json.loads(capsys.readouterr().out) + assert result["status"] == "success" + assert result["components_count"] == 2 + assert [publisher.publish_calls for publisher in FakePublisher.instances] == [ + [ + { + "component_path": first, + "image": "python:3.12", + "name": "One Config", + "description": None, + "annotations": None, + } + ], + [ + { + "component_path": second, + "image": "python:3.12", + "name": "Two Config", + "description": None, + "annotations": None, + } + ], + ] + + +def test_published_components_publish_config_array_uses_per_entry_controls(monkeypatch, tmp_path: Path, capsys): + first = _write_component(tmp_path / "one.yaml", name="One") + second = _write_component(tmp_path / "two.yaml", name="Two") + config = tmp_path / "publish-controls.yaml" + config.write_text( + "configs:\n" + f" - component_path: {first}\n" + " base_url: https://first.example\n" + " token: first-token\n" + " published_by: first@example.com\n" + " git_remote_sha: first-sha\n" + f" - component_path: {second}\n" + " dry_run: true\n" + " base_url: https://second.example\n" + " token: second-token\n" + " published_by: second@example.com\n" + " git_remote_sha: second-sha\n", + encoding="utf-8", + ) + fake_client = object() + client_calls: list[dict[str, Any]] = [] + FakePublisher.instances = [] + + def fake_client_from_options(**kwargs: Any) -> object: + client_calls.append(kwargs) + return fake_client + + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) + + app = cli.build_app() + run_app(app, ["sdk", "published-components", "publish", "--config", str(config)]) + + result = json.loads(capsys.readouterr().out) + assert result["status"] == "success" + assert result["components_count"] == 2 + assert client_calls == [ + { + "base_url": "https://first.example", + "token": "first-token", + "auth_header": None, + "header": None, + "include_env_credentials": False, + "command_name": "published-component commands", + } + ] + assert [publisher.kwargs for publisher in FakePublisher.instances] == [ + { + "dry_run": False, + "git_remote_sha": "first-sha", + "git_remote_branch": None, + "git_remote_url": None, + "git_root": None, + "published_by": "first@example.com", + "client": fake_client, + "logger": ANY, + }, + { + "dry_run": True, + "git_remote_sha": "second-sha", + "git_remote_branch": None, + "git_remote_url": None, + "git_root": None, + "published_by": "second@example.com", + "client": None, + "logger": ANY, + }, + ] + assert FakePublisher.instances[0].publish_calls[0]["component_path"] == first + assert FakePublisher.instances[1].publish_calls[0]["component_path"] == second + + +def test_published_components_deprecate_cli_wiring_and_config(monkeypatch, tmp_path: Path, capsys): + config = tmp_path / "deprecate.yaml" + config.write_text( + "digest: sha256:from-config\n" + "superseded_by: sha256:new\n" + "base_url: https://api.test\n" + "header:\n" + " - 'X-Test: yes'\n", + encoding="utf-8", + ) + fake_client = object() + client_calls: list[dict[str, Any]] = [] + deprecate_calls: list[dict[str, Any]] = [] + + def fake_client_from_options(**kwargs: Any) -> object: + client_calls.append(kwargs) + return fake_client + + def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict[str, Any]: + deprecate_calls.append({"client": client, "digest": digest, **kwargs}) + return {"success": True, "digest": digest, "superseded_by": kwargs.get("superseded_by")} + + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "deprecate_component", fake_deprecate_component) + + app = cli.build_app() + run_app(app, ["sdk", "published-components", "deprecate", "--config", str(config)]) + + result = json.loads(capsys.readouterr().out) + assert result == { + "digest": "sha256:from-config", + "success": True, + "superseded_by": "sha256:new", + } + assert client_calls == [ + { + "base_url": "https://api.test", + "token": None, + "auth_header": None, + "header": ["X-Test: yes"], + "include_env_credentials": False, + "command_name": "published-component commands", + } + ] + assert deprecate_calls == [ + { + "client": fake_client, + "digest": "sha256:from-config", + "superseded_by": "sha256:new", + "logger": ANY, + } + ] + + +def test_published_components_missing_native_api_uses_friendly_error(monkeypatch): + import tangle_cli + + for attr in ("component_inspector", "client", "models"): + if hasattr(tangle_cli, attr): + monkeypatch.delattr(tangle_cli, attr) + for name in list(sys.modules): + if name in { + "tangle_cli.component_inspector", + "tangle_cli.client", + "tangle_cli.models", + } or name.startswith("tangle_api"): + monkeypatch.delitem(sys.modules, name, raising=False) + + original_import = builtins.__import__ + + def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "tangle_api" or name.startswith("tangle_api."): + raise ModuleNotFoundError("No module named 'tangle_api'", name="tangle_api") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + app = cli.build_app() + + for command in ( + ["sdk", "published-components", "search", "demo"], + ["sdk", "published-components", "inspect", "demo"], + ["sdk", "published-components", "library"], + ): + 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 + + +def test_published_components_publish_log_type_none_suppresses_progress(tmp_path: Path, capsys): + component_path = _write_component(tmp_path / "component.yaml", name="Quiet") + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "published-components", + "publish", + str(component_path), + "--dry-run", + "--log-type", + "none", + ], + ) + + captured = capsys.readouterr() + assert json.loads(captured.out)["status"] == "success" + assert captured.err == "" + + +def test_components_and_published_components_help_reflect_api_split(capsys): + app = cli.build_app() + + run_app(app, ["sdk", "components", "--help"]) + output = capsys.readouterr().out + assert "generate" in output + assert "bump-version" in output + assert "publish" not in output + assert "deprecate" not in output + assert "publish-all" not in output + assert "base-url" not in output + assert "auth-header" not in output + assert "slack" not in output.lower() + assert "shopify" not in output.lower() + + run_app(app, ["sdk", "published-components", "--help"]) + published_output = capsys.readouterr().out + assert "search" in published_output + assert "inspect" in published_output + assert "library" in published_output + assert "publish" in published_output + assert "deprecate" in published_output + assert "publish-all" not in published_output + assert "slack" not in published_output.lower() + assert "shopify" not in published_output.lower() + + run_app(app, ["sdk", "published-components", "publish", "--help"]) + publish_help = capsys.readouterr().out + assert "base-url" in publish_help + assert "auth-header" in publish_help + assert "published-by" in publish_help + assert "--log-type" in publish_help + assert "slack" not in publish_help.lower() + assert "shopify" not in publish_help.lower() + assert "publish-all" not in publish_help + + for old_path in (["sdk", "components", "publish"], ["sdk", "components", "deprecate"], ["sdk", "components", "publish-all"]): + with pytest.raises(SystemExit) as exc_info: + app(old_path) + assert exc_info.value.code != 0 diff --git a/tests/test_dynamic_discovery_client.py b/tests/test_dynamic_discovery_client.py new file mode 100644 index 0000000..42bb3f1 --- /dev/null +++ b/tests/test_dynamic_discovery_client.py @@ -0,0 +1,307 @@ +import importlib +import json +import sys + +import httpx +import pytest + +from tangle_cli import api_schema +from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient + + +SCHEMA = { + "openapi": "3.1.0", + "paths": { + "/api/components/{digest}": { + "get": { + "tags": ["components"], + "summary": "Get component", + "parameters": [ + { + "name": "digest", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + "/api/published_components/": { + "get": { + "tags": ["components"], + "summary": "List published components", + "parameters": [ + {"name": "limit", "in": "query", "schema": {"type": "integer"}}, + { + "name": "tag", + "in": "query", + "schema": {"type": "array", "items": {"type": "string"}}, + }, + ], + }, + "post": { + "tags": ["components"], + "summary": "Create published component", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PublishedComponentCreate"} + } + } + }, + }, + }, + "/api/pipeline_runs/{id}/cancel": { + "post": { + "tags": ["pipelineRuns"], + "summary": "Cancel pipeline run", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + } + }, + }, + "components": { + "schemas": { + "PublishedComponentCreate": { + "allOf": [ + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": {"$ref": "#/components/schemas/Label"}, + } + }, + }, + ] + }, + "Label": {"type": "string"}, + } + }, +} + + +def json_response(method, url, payload, status_code=200, headers=None): + return httpx.Response( + status_code, + json=payload, + headers=headers or {"Content-Type": "application/json"}, + request=httpx.Request(method, url), + ) + + +def text_response(method, url, text, status_code=200, headers=None): + return httpx.Response( + status_code, + text=text, + headers=headers or {"Content-Type": "text/plain"}, + request=httpx.Request(method, url), + ) + + +def lower_headers(headers): + return {name.lower(): value for name, value in dict(headers).items()} + + +def test_from_cache_loads_cached_schema_without_network(monkeypatch, tmp_path): + calls = [] + + def fake_get(url, **kwargs): + calls.append(url) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(httpx, "get", fake_get) + api_schema.write_cached_schema(SCHEMA, "https://api.test") + + client = TangleDynamicDiscoveryClient.from_cache(base_url="https://api.test") + + assert client.operations == ( + "components.get", + "pipeline-runs.cancel", + "published-components.create", + "published-components.list", + ) + assert calls == [] + + +def test_from_cache_or_refresh_fetches_on_miss_then_reuses_cache(monkeypatch, tmp_path): + gets = [] + + def fake_get(url, **kwargs): + gets.append({"url": url, **kwargs}) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(httpx, "get", fake_get) + + first = TangleDynamicDiscoveryClient.from_cache_or_refresh( + base_url="https://api.test/", headers={"Cloud-Auth": "cloud-token"} + ) + second = TangleDynamicDiscoveryClient.from_cache_or_refresh(base_url="https://api.test") + + assert first.operations == second.operations + assert len(gets) == 1 + assert gets[0]["url"] == "https://api.test/openapi.json" + assert lower_headers(gets[0]["headers"])["cloud-auth"] == "cloud-token" + + +def test_request_call_and_dynamic_attribute_access(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"url": url}) + + monkeypatch.setattr(httpx, "request", fake_request) + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") + + response = client.request("components.get", digest="sha256:abc") + assert response.json() == {"url": "https://api.test/api/components/sha256%3Aabc"} + + payload = client.call("components.get", digest="sha256:def") + assert payload == {"url": "https://api.test/api/components/sha256%3Adef"} + + payload = client.components.get(digest="sha256:ghi") + assert payload == {"url": "https://api.test/api/components/sha256%3Aghi"} + + payload = client.published_components.list(limit=2, tag=["a", "b"]) + assert payload == { + "url": "https://api.test/api/published_components/?limit=2&tag=a&tag=b" + } + assert requests[-1]["method"] == "GET" + + +def test_pythonic_aliases_for_hyphenated_operations(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(httpx, "request", fake_request) + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") + + client.call("pipeline_runs.cancel", id="run/1") + client.pipeline_runs.cancel(id="run/2") + + assert requests[0]["url"] == "https://api.test/api/pipeline_runs/run%2F1/cancel" + assert requests[1]["url"] == "https://api.test/api/pipeline_runs/run%2F2/cancel" + + +def test_path_query_body_and_nested_ref_params(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setattr(httpx, "request", fake_request) + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") + + client.call("published-components.create", name="demo", labels=["stable"]) + + assert requests[-1]["method"] == "POST" + assert requests[-1]["url"] == "https://api.test/api/published_components/" + assert json.loads(requests[-1]["content"].decode()) == { + "name": "demo", + "labels": ["stable"], + } + + +def test_programmatic_string_body_does_not_read_at_file_reference(monkeypatch, tmp_path): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + secret_path = tmp_path / "secret.json" + secret_path.write_text('{"token":"secret"}', encoding="utf-8") + monkeypatch.setattr(httpx, "request", fake_request) + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") + + client.call("published-components.create", body=f"@{secret_path}") + + assert json.loads(requests[-1]["content"].decode()) == f"@{secret_path}" + + +def test_auth_header_and_env_precedence(monkeypatch): + requests = [] + + def fake_request(method, url, **kwargs): + requests.append({"method": method, "url": url, **kwargs}) + return json_response(method, url, {"ok": True}) + + monkeypatch.setenv("TANGLE_API_HEADERS", json.dumps({"Cloud-Auth": "env-cloud"})) + monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Basic env-auth") + monkeypatch.setattr(httpx, "request", fake_request) + + client = TangleDynamicDiscoveryClient.from_schema( + SCHEMA, + base_url="https://api.test", + token="bearer-token", + auth_header="Basic client-auth", + headers={"X-Client": "yes"}, + ) + client.call( + "components.get", + digest="abc", + auth_header="Basic call-auth", + headers={"Cloud-Auth": "call-cloud"}, + ) + + headers = lower_headers(requests[-1]["headers"]) + assert headers["authorization"] == "Basic call-auth" + assert headers["cloud-auth"] == "call-cloud" + assert headers["x-client"] == "yes" + + +def test_status_and_network_errors_are_httpx_errors(monkeypatch): + def fake_http_error(method, url, **kwargs): + return text_response(method, url, "not authorized", status_code=401) + + monkeypatch.setattr(httpx, "request", fake_http_error) + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + client.call("components.get", digest="abc") + assert exc_info.value.response.status_code == 401 + assert exc_info.value.response.text == "not authorized" + + def fake_network_error(method, url, **kwargs): + request = httpx.Request(method, url) + raise httpx.ConnectError("connection refused", request=request) + + monkeypatch.setattr(httpx, "request", fake_network_error) + with pytest.raises(httpx.ConnectError): + client.request("components.get", digest="abc") + + +def test_no_import_time_side_effects(monkeypatch, tmp_path): + calls = [] + + def fake_get(url, **kwargs): + calls.append(url) + return json_response("GET", url, SCHEMA) + + monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(sys, "argv", ["tangle", "api", "components", "get", "abc"]) + + import tangle_cli.dynamic_discovery_client as dynamic_discovery_client + + importlib.reload(dynamic_discovery_client) + + assert calls == [] diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..7becfee --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,141 @@ +"""Tests for :mod:`tangle_cli.logger`. + +The most important guarantee here is that the logger module has **no +hard runtime dependency on any CLI framework** (typer / click). It +imports only stdlib, so logger helpers keep working without any CLI-framework +imports. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap + +from tangle_cli.logger import ( + CaptureLogger, + ConsoleLogger, + Logger, + NullLogger, + get_default_logger, + run_with_logging, +) + + +class TestLoggers: + def test_console_logger_writes_to_stderr(self, capsys): + ConsoleLogger().info("hello") + captured = capsys.readouterr() + assert "hello" in captured.err + assert captured.out == "" + + def test_capture_logger_accumulates_messages(self): + cl = CaptureLogger() + cl.info("one") + cl.warn("two") + cl.error("three") + logs = cl.get_logs() or "" + assert "one" in logs and "two" in logs and "three" in logs + # ``error`` is tagged so callers can spot errors in collected logs. + assert "[error]" in logs + + def test_capture_logger_get_logs_none_when_empty(self): + assert CaptureLogger().get_logs() is None + + def test_null_logger_discards(self, capsys): + NullLogger().info("ignored") + NullLogger().warn("ignored") + NullLogger().error("ignored") + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_get_default_logger_returns_console_logger(self): + assert isinstance(get_default_logger(), ConsoleLogger) + + +class TestRunWithLogging: + """``run_with_logging`` must work with no third-party CLI framework installed.""" + + def test_console_with_dict_result_prints_json(self, capsys): + def fn(_logger: Logger) -> dict: + return {"hello": "world"} + + run_with_logging("console", fn) + captured = capsys.readouterr() + # The dict result is serialized to JSON on stdout. + parsed = json.loads(captured.out) + assert parsed == {"hello": "world"} + + def test_console_with_none_result_prints_nothing_to_stdout(self, capsys): + run_with_logging("console", lambda _logger: None) + captured = capsys.readouterr() + assert captured.out == "" + + def test_console_with_logger_writes_to_stderr(self, capsys): + def fn(logger: Logger) -> None: + logger.info("doing the thing") + return None + + run_with_logging("console", fn) + captured = capsys.readouterr() + assert "doing the thing" in captured.err + assert captured.out == "" + + def test_none_log_type_discards_logs_but_prints_result(self, capsys): + def fn(logger: Logger) -> dict: + logger.info("this should be discarded") + return {"ok": True} + + run_with_logging("none", fn) + captured = capsys.readouterr() + assert "discarded" not in captured.err + assert "discarded" not in captured.out + assert json.loads(captured.out) == {"ok": True} + + def test_string_result_prints_as_plain_text(self, capsys): + run_with_logging("console", lambda _logger: "plain text result") + captured = capsys.readouterr() + assert captured.out.strip() == "plain text result" + + +class TestNoTyperDependency: + """Regression guard: the logger module must not import ``typer``. + + Spawn a clean subprocess that monkeypatches ``typer`` to make the + import fail, then exercise :func:`run_with_logging`. If + ``_print_result`` ever re-introduces ``import typer``, this test + will fail loudly with ``ModuleNotFoundError``. + """ + + def test_run_with_logging_works_without_typer(self): + script = textwrap.dedent(""" + import sys + + # Make ``import typer`` raise inside this subprocess to simulate + # a clean ``pip install tangle-cli`` environment. Use the + # modern ``find_spec`` API (PEP 451); ``find_module`` / + # ``load_module`` are deprecated since 3.4 and removed in 3.12. + class _Blocker: + def find_spec(self, fullname, path=None, target=None): + if fullname == "typer" or fullname.startswith("typer."): + raise ModuleNotFoundError("typer is not installed") + return None + + sys.meta_path.insert(0, _Blocker()) + + from tangle_cli.logger import run_with_logging + run_with_logging("console", lambda logger: {"ok": True}) + """) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + # Subprocess must exit cleanly and print the result as JSON. + assert result.returncode == 0, ( + f"run_with_logging crashed when typer is missing.\n" + f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}" + ) + assert '"ok": true' in result.stdout diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..9dc189e --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,311 @@ +"""Round-trip tests for the API-contract dataclasses in :mod:`tangle_cli.models`.""" + +from __future__ import annotations + +from tangle_api.generated.models import ( + ArtifactData, + ComponentSpec as GeneratedComponentSpec, + GetArtifactInfoResponse, + GetExecutionInfoResponse, +) +from tangle_cli.models import ( + ArtifactInfo, + ComponentInfo, + ComponentSpec, + ContainerState, + GraphExecutionState, + PipelineRun, + SecretInfo, + UserInfo, +) +from tangle_cli.utils import add_official_prefix + + +class TestPipelineRun: + def test_from_dict_preserves_raw(self): + data = {"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"} + run = PipelineRun.from_dict(data) + assert run.id == "run-1" + assert run.root_execution_id == "exec-1" + assert run.created_by == "alice" + assert run.raw == data + + def test_from_dict_with_missing_optionals(self): + run = PipelineRun.from_dict({"id": "run-1"}) + assert run.id == "run-1" + assert run.root_execution_id is None + assert run.created_by is None + + +class TestGraphExecutionState: + def test_status_totals_aggregates(self): + state = GraphExecutionState.from_dict({ + "child_execution_status_stats": { + "exec-a": {"SUCCEEDED": 1}, + "exec-b": {"SUCCEEDED": 2, "RUNNING": 1, "FAILED": 1}, + } + }) + assert state.status_totals == {"SUCCEEDED": 3, "RUNNING": 1, "FAILED": 1} + + def test_failed_execution_ids(self): + state = GraphExecutionState.from_dict({ + "child_execution_status_stats": { + "exec-a": {"SUCCEEDED": 1}, + "exec-b": {"FAILED": 1}, + "exec-c": {"SYSTEM_ERROR": 1}, + "exec-d": {"RUNNING": 1}, + } + }) + assert set(state.failed_execution_ids) == {"exec-b", "exec-c"} + + +class TestComponentSpec: + def test_component_spec_is_generated_model_with_extensions(self): + assert ComponentSpec is GeneratedComponentSpec + assert ComponentSpec.__mro__[1].__name__ == "ComponentSpecExtensions" + + def test_from_yaml_basic(self): + yaml_text = """\ +name: my-component +description: a test component +metadata: + annotations: + version: 1.2.3 +inputs: + - {name: in1, type: String} +outputs: + - {name: out1, type: String} +implementation: + container: + image: alpine:3.18 +""" + spec = ComponentSpec.from_yaml(yaml_text) + assert spec.name == "my-component" + assert spec.version == "1.2.3" + assert spec.description == "a test component" + assert spec.inputs == [{"name": "in1", "type": "String"}] + assert spec.outputs == [{"name": "out1", "type": "String"}] + assert spec.implementation == {"container": {"image": "alpine:3.18"}} + assert spec.text == yaml_text + + def test_from_dict_parses_text_when_spec_missing(self): + api_response = { + "digest": "abc123", + "text": "name: x\nmetadata:\n annotations:\n version: \"0.1\"\n", + } + spec = ComponentSpec.from_dict(api_response) + assert spec.digest == "abc123" + assert spec.name == "x" + assert spec.version == "0.1" + + def test_from_dict_accepts_raw_component_spec_shape(self): + spec = ComponentSpec.from_dict({ + "name": "raw-component", + "description": "direct OpenAPI ComponentSpecOutput response", + "metadata": {"annotations": {"version": "1.0.0"}}, + "inputs": [{"name": "query", "type": "String"}], + "outputs": [{"name": "result", "type": "String"}], + "implementation": {"container": {"image": "alpine"}}, + }) + + assert spec.name == "raw-component" + assert spec.version == "1.0.0" + assert spec.description == "direct OpenAPI ComponentSpecOutput response" + assert spec.inputs == [{"name": "query", "type": "String"}] + assert spec.outputs == [{"name": "result", "type": "String"}] + assert spec.implementation == {"container": {"image": "alpine"}} + assert spec.data["name"] == "raw-component" + + def test_strip_implementation_removes_container(self): + spec = ComponentSpec.from_dict({ + "digest": "d", + "text": "", + "spec": { + "name": "c", + "implementation": {"container": {"image": "alpine"}}, + }, + }) + assert spec.implementation == {"container": {"image": "alpine"}} + spec.strip_implementation() + assert spec.implementation is None + assert "implementation" not in spec.data + + def test_ensure_digest_from_text(self): + spec = ComponentSpec(text="name: c\n") + digest = spec.ensure_digest() + assert digest + assert spec.digest == digest + # Calling again is idempotent. + assert spec.ensure_digest() == digest + + def test_roundtrip_via_yaml(self): + yaml_text = "name: roundtrip\nmetadata:\n annotations:\n version: '2.0'\n" + spec = ComponentSpec.from_yaml(yaml_text) + re_dumped = spec.to_yaml() + # Re-parsing the dumped YAML should yield the same name/version. + spec2 = ComponentSpec.from_yaml(re_dumped) + assert spec2.name == "roundtrip" + assert spec2.version == "2.0" + + +class TestComponentInfo: + def test_from_dict_to_dict_minimal(self): + info = ComponentInfo.from_dict({ + "name": "[Official] foo", + "digest": "abc", + "version": "1.0", + "published_by": "alice@example.com", + "deprecated": False, + }) + out = info.to_dict() + assert out == { + "digest": "abc", + "version": "1.0", + "published_by": "alice@example.com", + "deprecated": False, + } + + +class TestContainerState: + def test_resolves_pod_name_from_kubernetes_debug_info(self): + state = ContainerState.from_dict({ + "status": "RUNNING", + "debug_info": { + "kubernetes": {"pod_name": "pod-xyz", "namespace": "ns-1"}, + }, + }) + assert state.status == "RUNNING" + assert state.pod_name == "pod-xyz" + assert state.namespace == "ns-1" + + def test_falls_back_to_kubernetes_job_name(self): + state = ContainerState.from_dict({ + "status": "SUCCEEDED", + "debug_info": { + "kubernetes_job": {"job_name": "job-abc"}, + }, + }) + assert state.pod_name == "job-abc" + + +class TestArtifactInfo: + def test_from_response_accepts_mapping_artifact_data(self): + response = GetArtifactInfoResponse.from_dict({ + "id": "artifact-1", + "artifact_data": { + "uri": "gs://bucket/path", + "total_size": 42, + "is_dir": False, + "hash": "sha256:abc", + "created_at": "2026-01-01T00:00:00Z", + }, + }) + + info = ArtifactInfo.from_response(response, key="output") + + assert info == ArtifactInfo( + id="artifact-1", + uri="gs://bucket/path", + key="output", + total_size=42, + is_dir=False, + hash="sha256:abc", + created_at="2026-01-01T00:00:00Z", + ) + + def test_from_response_accepts_generated_artifact_data_model(self): + response = GetArtifactInfoResponse( + id="artifact-2", + artifact_data=ArtifactData( + uri="gs://bucket/model", + total_size=128, + is_dir=True, + hash="sha256:def", + created_at="2026-01-02T00:00:00Z", + ), + ) + + info = ArtifactInfo.from_response(response) + + assert info.id == "artifact-2" + assert info.uri == "gs://bucket/model" + assert info.total_size == 128 + assert info.is_dir is True + assert info.hash == "sha256:def" + assert info.created_at == "2026-01-02T00:00:00Z" + + def test_from_response_accepts_attribute_artifact_data_object(self): + class ArtifactDataObject: + uri = "gs://bucket/object" + total_size = 7 + is_dir = False + hash = "sha256:ghi" + created_at = "2026-01-03T00:00:00Z" + + class ResponseObject: + id = "artifact-3" + artifact_data = ArtifactDataObject() + + info = ArtifactInfo.from_response(ResponseObject()) + + assert info.id == "artifact-3" + assert info.uri == "gs://bucket/object" + assert info.total_size == 7 + assert info.is_dir is False + assert info.hash == "sha256:ghi" + assert info.created_at == "2026-01-03T00:00:00Z" + + def test_from_dict_keeps_existing_mapping_behavior(self): + info = ArtifactInfo.from_dict({ + "id": "artifact-4", + "artifact_data": {"uri": "gs://bucket/from-dict", "total_size": 1}, + }) + + assert info.id == "artifact-4" + assert info.uri == "gs://bucket/from-dict" + assert info.total_size == 1 + + +class TestUserAndSecret: + def test_user_info_minimal(self): + u = UserInfo(id="u-1", permissions=["read"]) + assert u.id == "u-1" + assert u.permissions == ["read"] + + def test_secret_info_from_dict(self): + s = SecretInfo.from_dict({ + "secret_name": "mysecret", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "description": "test", + }) + assert s.secret_name == "mysecret" + assert s.description == "test" + assert s.expires_at is None + + +class TestHelpers: + def test_add_official_prefix_idempotent(self): + assert add_official_prefix("foo") == "[Official] foo" + assert add_official_prefix("[Official] foo") == "[Official] foo" + assert add_official_prefix(None) is None + assert add_official_prefix("") == "" + + +class TestGetExecutionInfoResponse: + def test_execution_details_generated_model_has_extensions(self): + assert GetExecutionInfoResponse.__mro__[1].__name__ == "GetExecutionInfoResponseExtensions" + + def test_from_dict_parses_artifacts(self): + ed = GetExecutionInfoResponse.from_dict({ + "id": "exec-1", + "task_spec": {"componentRef": {"spec": {"name": "task"}}}, + "input_artifacts": {"in1": {"id": "art-1"}}, + "output_artifacts": {"out1": {"id": "art-2"}, "noisy": {}}, + }) + assert ed.id == "exec-1" + assert ed.input_artifacts == {"in1": "art-1"} + # Entries without an "id" key are dropped. + assert ed.output_artifacts == {"out1": "art-2"} + assert ed.raw["id"] == "exec-1" + assert ed.child_executions == {} diff --git a/tests/test_module_bundler.py b/tests/test_module_bundler.py new file mode 100644 index 0000000..aeb0d98 --- /dev/null +++ b/tests/test_module_bundler.py @@ -0,0 +1,362 @@ +"""Tests for ``ModuleBundler`` ordering and dependency analysis. + +Focused unit tests for the topological sort that ensures bundle modules +execute dependency-first. Alphabetical bundle order can execute a dependent +before its dependency, breaking module-level references like +``FOO = bbb.bar()``. +""" + +from __future__ import annotations + +import base64 +import json +import textwrap +import zlib + +from tangle_cli.module_bundler import ( + ModuleBundler, + _import_node_targets, + _iter_module_level_nodes, + _module_level_dependencies, + _topological_order, +) + + +def _decode(b64: str) -> dict[str, str]: + """Mirror of the runtime injection's decompress step.""" + return json.loads(zlib.decompress(base64.b64decode(b64))) + + +class TestTopologicalOrder: + def test_dependency_executes_before_dependent(self): + """Regression test for issue #30197. + + ``aaa`` references ``bbb`` at module load time, so ``bbb`` must + be executed first even though ``aaa`` < ``bbb`` alphabetically. + """ + sources = { + "aaa": "import bbb\n\nFOO = bbb.bar()\n", + "bbb": "def bar():\n return 'BIZ'\n", + } + + order = _topological_order(sources) + + assert order.index("bbb") < order.index("aaa") + + def test_encode_emits_topologically_ordered_dict(self): + """``ModuleBundler.encode`` round-trips through the same order.""" + sources = { + "aaa": "import bbb\n\nFOO = bbb.bar()\n", + "bbb": "def bar():\n return 'BIZ'\n", + } + + b64 = ModuleBundler.encode(sources) + assert b64 is not None + decoded = _decode(b64) + + assert list(decoded.keys()) == ["bbb", "aaa"] + + def test_independent_modules_keep_deterministic_order(self): + """With no inter-module deps, fall back to (depth, alphabetical).""" + sources = { + "ccc": "X = 1\n", + "aaa": "Y = 2\n", + "bbb": "Z = 3\n", + } + + order = _topological_order(sources) + + assert order == ["aaa", "bbb", "ccc"] + + def test_chain_dependency(self): + """``a -> b -> c`` must execute as ``c, b, a``.""" + sources = { + "a": "import b\nVAL = b.B\n", + "b": "import c\nB = c.C\n", + "c": "C = 42\n", + } + + order = _topological_order(sources) + + assert order == ["c", "b", "a"] + + def test_parent_package_runs_before_submodule(self): + """Submodules must wait for their parent package's ``__init__``.""" + sources = { + "pkg.sub": "from pkg import THING\n", + "pkg": "THING = 1\n", + } + + order = _topological_order(sources) + + assert order.index("pkg") < order.index("pkg.sub") + + def test_relative_import_creates_dependency_edge(self): + """``from . import sibling`` must order ``sibling`` before us.""" + sources = { + "pkg": "", + "pkg.user": "from . import helper\n\nVAL = helper.value()\n", + "pkg.helper": "def value():\n return 1\n", + } + + order = _topological_order(sources) + + assert order.index("pkg.helper") < order.index("pkg.user") + + def test_parent_imports_child_does_not_create_cycle(self): + """``from . import sibling`` *inside parent's __init__.py* is fine. + + This is a common pattern (re-exporting submodules). An earlier + draft of the dependency analysis added a blanket "parent before + child" edge, which combined with the parent's relative import + formed a cycle and silently fell back to the legacy alphabetical + order — defeating the whole fix. This test guards against that + regression. + """ + sources = { + "mylib": "from . import helpers\n", + "mylib.helpers": "HELP = True\n", + "mylib.core": "def process(): pass\n", + } + + order = _topological_order(sources) + + # helpers must execute before mylib because mylib's body imports it. + assert order.index("mylib.helpers") < order.index("mylib") + + def test_from_pkg_sub_import_module_form(self): + """``from pkg.sub import mod`` should depend on ``pkg.sub.mod``.""" + sources = { + "pkg": "", + "pkg.sub": "", + "pkg.sub.mod": "VALUE = 7\n", + "pkg.consumer": "from pkg.sub import mod\n\nX = mod.VALUE\n", + } + + order = _topological_order(sources) + + assert order.index("pkg.sub.mod") < order.index("pkg.consumer") + + def test_lazy_import_does_not_create_edge(self): + """Imports inside a function body do not constrain load order. + + Otherwise common patterns like ``def f(): import sibling`` would + introduce spurious cycles. + """ + sources = { + # ``aaa`` only imports ``bbb`` lazily, so it is *not* a real + # module-load-time dependency. ``bbb`` imports ``aaa`` at + # the top, so the only real edge is ``aaa -> ... `` (none) and + # ``bbb -> aaa``. + "aaa": "def f():\n import bbb\n return bbb.X\n", + "bbb": "import aaa\n\nVAL = 1\n", + } + + order = _topological_order(sources) + + assert order.index("aaa") < order.index("bbb") + + def test_cycle_falls_back_to_alphabetical(self): + """Module-level cycles (which would also fail in real Python) fall back. + + The output must still be deterministic. + """ + sources = { + "bbb": "import aaa\n\nVAL = aaa.X\n", + "aaa": "import bbb\n\nVAL = bbb.X\n", + } + + order = _topological_order(sources) + + # (depth, alphabetical) fallback. + assert order == ["aaa", "bbb"] + + def test_empty_input(self): + assert _topological_order({}) == [] + assert ModuleBundler.encode({}) is None + + +class TestModuleLevelDependencies: + def test_picks_up_top_level_import(self): + deps = _module_level_dependencies( + "aaa", + "import bbb\nFOO = bbb.bar()\n", + {"aaa", "bbb"}, + ) + assert deps == {"bbb"} + + def test_skips_imports_inside_functions(self): + deps = _module_level_dependencies( + "aaa", + "def f():\n import bbb\n return bbb.x\n", + {"aaa", "bbb"}, + ) + assert deps == set() + + def test_no_implicit_parent_edge(self): + """A child without an explicit parent import has no parent edge. + + Pass 1 of the runtime injection pre-registers every module in + ``sys.modules``, so the child only needs the parent exec'd first + if it actually references the parent's attributes — which it + would do via an explicit ``import`` / ``from`` statement. + """ + deps = _module_level_dependencies( + "pkg.sub", + "X = 1\n", + {"pkg", "pkg.sub"}, + ) + assert deps == set() + + def test_explicit_parent_import_creates_edge(self): + deps = _module_level_dependencies( + "pkg.sub", + "from pkg import THING\n", + {"pkg", "pkg.sub"}, + ) + assert deps == {"pkg"} + + def test_ignores_unbundled_imports(self): + """Standard library and third-party imports are not bundle deps.""" + deps = _module_level_dependencies( + "aaa", + "import os\nimport pandas\n", + {"aaa"}, + ) + assert deps == set() + + def test_handles_syntax_error_gracefully(self): + """Unparseable source contributes no deps and never raises.""" + deps = _module_level_dependencies( + "pkg.sub", + "this is not valid python @@@\n", + {"pkg", "pkg.sub"}, + ) + assert deps == set() + + def test_self_reference_excluded(self): + """A module never depends on itself even with weird ``from`` forms.""" + deps = _module_level_dependencies( + "aaa", + "from aaa import x\n", # nonsensical but shouldn't loop + {"aaa"}, + ) + assert deps == set() + + +class TestImportNodeTargetsHelpers: + def test_module_level_iterator_skips_function_bodies(self): + import ast + + tree = ast.parse(textwrap.dedent("""\ + import top_level + + def fn(): + import nested + """)) + + names = [ + alias.name + for node in _iter_module_level_nodes(tree) + if isinstance(node, ast.Import) + for alias in node.names + ] + + assert names == ["top_level"] + + def test_relative_from_import_resolves_against_context(self): + import ast + + node = ast.parse("from .helper import fn\n").body[0] + targets = _import_node_targets(node, pkg_context="pkg") + # ``fn`` may be a module or attribute; both are emitted and + # filtered downstream by the bundled-set check. + assert "pkg.helper" in targets + assert "pkg.helper.fn" in targets + + +class TestRuntimeBundleExecution: + """End-to-end: encode a bundle, run the injection snippet, observe. + + These tests exec the *exact* snippet shipped in the generated + component (``ModuleBundler.build_injection``) so a regression in + either the encode order or the runtime exec loop is caught. No + pre-existing test exercised this path — every earlier bundle test + stopped at "the YAML contains ``_EMBEDDED_MODULES``". + """ + + @staticmethod + def _exec_bundle(sources: dict[str, str], driver: str) -> dict: + """Encode *sources*, run the injection, then run *driver*. + + Cleans up any bundled module names from ``sys.modules`` after + execution so tests stay isolated. + """ + import sys + + b64 = ModuleBundler.encode(sources) + assert b64 is not None + snippet = ModuleBundler.build_injection(b64) + "\n" + driver + ns: dict = {} + try: + exec(snippet, ns) + finally: + for name in list(sys.modules): + if name in sources or any(name.startswith(p + ".") for p in sources): + del sys.modules[name] + return ns + + def test_issue_30197_repro_runs_correctly(self): + """Reproduces the exact failure in issue #30197. + + Before the topological-order fix, ``aaa`` (alphabetically first) + was exec'd before ``bbb``, so ``FOO = bbb.bar()`` raised + ``AttributeError: module 'bbb' has no attribute 'bar'``. + """ + sources = { + "aaa": "import bbb\n\nFOO = bbb.bar()\n\ndef do():\n return FOO\n", + "bbb": "def bar():\n return 'BIZ'\n", + } + + ns = self._exec_bundle(sources, "import aaa\nresult = aaa.do()\n") + + assert ns["result"] == "BIZ" + + def test_chain_dependency_runs_correctly(self): + """``a -> b -> c`` chain with module-level attribute access.""" + sources = { + "a": "import b\n\nVAL = b.B + 1\n", + "b": "import c\n\nB = c.C * 10\n", + "c": "C = 4\n", + } + + ns = self._exec_bundle(sources, "import a\nresult = a.VAL\n") + + assert ns["result"] == 41 + + def test_nested_package_relative_import_runs_correctly(self): + sources = { + "pkg": "", + "pkg.sub": "from . import helpers\n\nVALUE = helpers.VALUE\n", + "pkg.sub.helpers": "VALUE = 'nested'\n", + } + + ns = self._exec_bundle(sources, "import pkg.sub\nresult = pkg.sub.VALUE\n") + + assert ns["result"] == "nested" + + def test_parent_init_relative_import_runs_correctly(self): + """Common pattern: parent ``__init__`` re-exports a sibling. + + Combines two patterns that earlier drafts handled poorly: a + relative ``from . import helpers`` in the parent and a + consumer that reaches into the helper. + """ + sources = { + "mylib": "from . import helpers\n\nGREETING = helpers.HELLO\n", + "mylib.helpers": "HELLO = 'hi'\n", + } + + ns = self._exec_bundle(sources, "import mylib\nresult = mylib.GREETING\n") + + assert ns["result"] == "hi" diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..5cb19a3 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +from tangle_cli.openapi import codegen + + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _build_wheel(tmp_path: Path, *args: str) -> Path: + out_dir = tmp_path / "dist" + command = ["uv", "build", "--wheel", "--out-dir", str(out_dir), *args] + subprocess.run(command, cwd=_REPO_ROOT, check=True, text=True, capture_output=True) + wheels = sorted(out_dir.glob("*.whl")) + assert wheels, f"no wheel built by {' '.join(command)}" + return wheels[-1] + + +def _write_import_stubs(path: Path) -> None: + path.mkdir() + _write_runtime_stubs(path) + (path / "cyclopts.py").write_text( + "class App:\n" + " def __init__(self, *args, **kwargs): pass\n" + " def command(self, obj=None, **kwargs):\n" + " if obj is not None:\n" + " return obj\n" + " def decorator(fn): return fn\n" + " return decorator\n" + " def __call__(self, *args, **kwargs): pass\n" + " def default(self, fn): return fn\n" + "\n" + "def Parameter(*args, **kwargs): return object()\n", + encoding="utf-8", + ) + + +def _write_runtime_stubs(path: Path) -> None: + path.mkdir(exist_ok=True) + (path / "httpx.py").write_text("", encoding="utf-8") + (path / "platformdirs.py").write_text("", encoding="utf-8") + (path / "yaml.py").write_text( + "class ScalarNode:\n" + " pass\n" + "\n" + "class SafeDumper:\n" + " @classmethod\n" + " def add_representer(cls, *args, **kwargs): pass\n" + "\n" + "def add_representer(*args, **kwargs): pass\n" + "def safe_load(*args, **kwargs): return None\n" + "def dump(*args, **kwargs): return ''\n", + encoding="utf-8", + ) + (path / "requests.py").write_text( + "class Session:\n" + " def request(self, *args, **kwargs):\n" + " raise RuntimeError('request stub should not be called')\n" + "\n" + "class Response:\n" + " pass\n", + encoding="utf-8", + ) + + +def _write_consumer_tangle_api(path: Path) -> Path: + source_root = path / "src" + generated_dir = source_root / "tangle_api" / "generated" + generated_dir.mkdir(parents=True) + (source_root / "tangle_api" / "__init__.py").write_text("", encoding="utf-8") + (generated_dir / "__init__.py").write_text("", encoding="utf-8") + (generated_dir / "models.py").write_text( + "class ComponentSpec:\n" + " source = 'consumer-local'\n" + " @classmethod\n" + " def from_dict(cls, data):\n" + " return cls()\n" + "\n" + "class GetExecutionInfoResponse:\n" + " source = 'consumer-local'\n" + " @classmethod\n" + " def from_dict(cls, data):\n" + " return cls()\n", + encoding="utf-8", + ) + (generated_dir / "operations.py").write_text( + "class GeneratedTangleApiOperations:\n" + " def consumer_generated_marker(self):\n" + " return 'consumer-local-operations'\n", + encoding="utf-8", + ) + return source_root + + +def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: + wheel = _build_wheel(tmp_path) + stubs = tmp_path / "stubs" + _write_import_stubs(stubs) + + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + metadata_name = next(name for name in names 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 not any(name.startswith("tangle_api/") for name in names) + assert "tangle_cli/openapi/openapi.json" not in names + assert "Requires-Dist: tangle-api==0.1.0" not in requires_dist + assert "Requires-Dist: tangle-api==0.1.0 ; extra == 'native'" in requires_dist + + env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(wheel), str(stubs)])} + subprocess.run( + [ + sys.executable, + "-S", + "-c", + "import tangle_cli; " + "import tangle_cli.openapi.codegen; " + "import tangle_cli.dynamic_discovery_client; " + "import tangle_cli.cli; " + "tangle_cli.cli.build_app(); " + "assert not hasattr(tangle_cli, 'TangleApiClient')", + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) + + +def test_tangle_cli_wheel_api_refresh_builds_without_native_tangle_api(tmp_path) -> None: + wheel = _build_wheel(tmp_path) + stubs = tmp_path / "stubs" + _write_import_stubs(stubs) + env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(wheel), str(stubs)])} + + subprocess.run( + [ + sys.executable, + "-S", + "-c", + "import importlib.util; " + "import sys; " + "assert importlib.util.find_spec('tangle_api') is None; " + "sys.argv = ['tangle', 'api', 'refresh']; " + "import tangle_cli.cli; " + "tangle_cli.cli.build_app()", + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) + + +def test_tangle_cli_wheel_binds_to_consumer_local_tangle_api(tmp_path) -> None: + cli_wheel = _build_wheel(tmp_path / "cli") + 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)]), + } + + subprocess.run( + [ + sys.executable, + "-S", + "-c", + "import tangle_api.generated.models as generated_models; " + "from tangle_cli.client import TangleApiClient; " + "import tangle_cli.client as client_module; " + "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 generated_models.ComponentSpec.source == 'consumer-local'; " + "assert generated_models.__file__.startswith(%r)" % str(consumer_source), + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) + + +def test_codegen_output_imports_as_consumer_local_tangle_api(tmp_path) -> None: + source_root = tmp_path / "consumer_src" + generated_dir = source_root / "tangle_api" / "generated" + (source_root / "tangle_api").mkdir(parents=True) + (source_root / "tangle_api" / "__init__.py").write_text("", encoding="utf-8") + openapi = tmp_path / "openapi.json" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": { + "/api/foo": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/FooResponse"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "FooResponse": { + "type": "object", + "properties": {"id": {"type": "string"}}, + } + } + }, + }), + encoding="utf-8", + ) + + codegen.generate(openapi, generated_dir, model_extension_module="") + + env = {**os.environ, "PYTHONPATH": str(source_root)} + subprocess.run( + [ + sys.executable, + "-c", + "from pathlib import Path; " + "from tangle_api.generated.models import FooResponse; " + "from tangle_api.generated.operations import GeneratedTangleApiOperations; " + "import tangle_api.generated.models as models; " + "assert Path(models.__file__).resolve().is_relative_to(Path(%r).resolve()); " + "assert FooResponse.__name__ == 'FooResponse'; " + "assert '_FooResponseGenerated' not in getattr(models, '__all__'); " + "assert GeneratedTangleApiOperations.__name__ == 'GeneratedTangleApiOperations'" + % str(source_root), + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) + + +def test_native_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: + assert "tangle_api/schema/__init__.py" in archive.namelist() + assert "tangle_api/schema/openapi.json" in archive.namelist() + env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} + + subprocess.run( + [ + sys.executable, + "-c", + "from tangle_cli.client import TangleApiClient; " + "from tangle_cli.openapi.parser import load_openapi_schema; " + "assert TangleApiClient.__name__ == 'TangleApiClient'; " + "assert 'paths' in load_openapi_schema()", + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) diff --git a/tests/test_pipeline_dehydrator.py b/tests/test_pipeline_dehydrator.py new file mode 100644 index 0000000..e9aed29 --- /dev/null +++ b/tests/test_pipeline_dehydrator.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from tangle_cli import utils +from tangle_cli.pipeline_dehydrator import ( + DehydrateChoice, + Jinja2ExportResult, + PipelineDehydrator, + _build_subgraph_processing_queue, + _extract_input_defaults, +) + + +def _leaf_spec(name: str, *, canonical_url: str | None = None) -> dict[str, Any]: + annotations = {} + if canonical_url: + annotations["canonical_location"] = canonical_url + return { + "name": name, + "metadata": {"annotations": annotations}, + "implementation": {"container": {"image": "example/image:latest"}}, + } + + +def _task(name: str, digest: str, *, canonical_url: str | None = None) -> dict[str, Any]: + return {"componentRef": {"name": name, "digest": digest, "spec": _leaf_spec(name, canonical_url=canonical_url)}} + + +def _pipeline(tasks: dict[str, Any]) -> dict[str, Any]: + return {"name": "Pipeline", "implementation": {"graph": {"tasks": tasks}}} + + +class FakeClient: + def __init__(self, found: set[str]) -> None: + self.found = found + self.calls: list[str] = [] + + def get_component_spec(self, digest: str) -> dict[str, Any]: + self.calls.append(digest) + if digest not in self.found: + raise KeyError(digest) + return {"name": "found"} + + +def test_pipeline_dehydrator_replaces_refs_by_explicit_choice(tmp_path: Path) -> None: + data = _pipeline({"task": _task("Leaf Component", "digest-1", canonical_url="https://example.test/leaf.yaml")}) + + digest_result = PipelineDehydrator({"": DehydrateChoice.DIGEST}, output_file=tmp_path / "out.yaml").dehydrate(data) + assert digest_result["implementation"]["graph"]["tasks"]["task"]["componentRef"] == {"digest": "digest-1"} + + name_result = PipelineDehydrator({"": DehydrateChoice.NAME}, output_file=tmp_path / "out.yaml").dehydrate(data) + assert name_result["implementation"]["graph"]["tasks"]["task"]["componentRef"] == {"name": "Leaf Component"} + + url_result = PipelineDehydrator({"": DehydrateChoice.URL}, output_file=tmp_path / "out.yaml").dehydrate(data) + assert url_result["implementation"]["graph"]["tasks"]["task"]["componentRef"] == { + "url": "https://example.test/leaf.yaml" + } + + keep_result = PipelineDehydrator({"": DehydrateChoice.KEEP}, output_file=tmp_path / "out.yaml").dehydrate(data) + assert "spec" in keep_result["implementation"]["graph"]["tasks"]["task"]["componentRef"] + + +def test_pipeline_dehydrator_construction_is_auth_env_safe(monkeypatch: pytest.MonkeyPatch) -> None: + """Auth-free dehydration construction must not require TANGLE_API_URL.""" + + monkeypatch.setenv("TANGLE_API_TOKEN", "token") + monkeypatch.delenv("TANGLE_API_URL", raising=False) + + dehydrator = PipelineDehydrator({"": DehydrateChoice.DIGEST}) + + assert dehydrator.remembered_choices == {"": DehydrateChoice.DIGEST} + + +def test_pipeline_dehydrator_auto_with_url_does_not_create_client( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Auto mode should not create a client when canonical URL is enough to decide.""" + + def fail_get_client(_self): + raise AssertionError("client should not be created") + + monkeypatch.setattr(PipelineDehydrator, "_get_client", fail_get_client) + data = _pipeline({"canonical": _task("Canonical", "digest-url", canonical_url="https://example.test/canonical.yaml")}) + + result = PipelineDehydrator({"": DehydrateChoice.AUTO}, output_file=tmp_path / "out.yaml").dehydrate(data) + + tasks = result["implementation"]["graph"]["tasks"] + assert tasks["canonical"]["componentRef"] == {"url": "https://example.test/canonical.yaml"} + + +def test_pipeline_dehydrator_auto_lazily_creates_client_for_library_lookup(tmp_path: Path) -> None: + """Auto mode creates a default client only when a library lookup is needed.""" + + client = FakeClient({"digest-found"}) + + class LazyDehydrator(PipelineDehydrator): + def _get_client(self): + return client + + data = _pipeline({"published": _task("Published", "digest-found")}) + + result = LazyDehydrator({"": DehydrateChoice.AUTO}, output_file=tmp_path / "out.yaml").dehydrate(data) + + tasks = result["implementation"]["graph"]["tasks"] + assert tasks["published"]["componentRef"] == {"digest": "digest-found"} + assert client.calls == ["digest-found"] + + +def test_pipeline_dehydrator_auto_falls_back_to_file_when_client_creation_fails( + tmp_path: Path, +) -> None: + """Auto mode should stay local when no safe API client can be created.""" + + class UnavailableClientDehydrator(PipelineDehydrator): + def _api_client(self): + raise SystemExit("missing api configuration") + + data = _pipeline({"local": _task("Local Only", "digest-missing")}) + + result = UnavailableClientDehydrator( + {"": DehydrateChoice.AUTO}, + output_file=tmp_path / "out.yaml", + ).dehydrate(data) + + tasks = result["implementation"]["graph"]["tasks"] + assert tasks["local"]["componentRef"] == {"url": "file://./components/local_only.yaml"} + saved_component = yaml.safe_load((tmp_path / "components" / "local_only.yaml").read_text(encoding="utf-8")) + assert saved_component["name"] == "Local Only" + + +def test_pipeline_dehydrator_auto_uses_url_digest_then_file(tmp_path: Path) -> None: + data = _pipeline( + { + "canonical": _task("Canonical", "digest-url", canonical_url="https://example.test/canonical.yaml"), + "published": _task("Published", "digest-found"), + "local": _task("Local Only", "digest-missing"), + } + ) + client = FakeClient({"digest-found"}) + + result = PipelineDehydrator( + {"": DehydrateChoice.AUTO}, + output_file=tmp_path / "out.yaml", + client=client, + ).dehydrate(data) + + tasks = result["implementation"]["graph"]["tasks"] + assert tasks["canonical"]["componentRef"] == {"url": "https://example.test/canonical.yaml"} + assert tasks["published"]["componentRef"] == {"digest": "digest-found"} + assert tasks["local"]["componentRef"] == {"url": "file://./components/local_only.yaml"} + assert client.calls == ["digest-found", "digest-missing"] + saved_component = yaml.safe_load((tmp_path / "components" / "local_only.yaml").read_text(encoding="utf-8")) + assert saved_component["name"] == "Local Only" + + +def test_pipeline_dehydrator_auto_extracts_subgraphs_and_rewrites_relative_urls(tmp_path: Path) -> None: + subgraph_spec = { + "name": "Nested Subgraph", + "implementation": {"graph": {"tasks": {"leaf": _task("Inner Leaf", "digest-inner")}}}, + } + data = _pipeline({"nested": {"componentRef": {"name": "Nested Subgraph", "digest": "digest-sub", "spec": subgraph_spec}}}) + + result = PipelineDehydrator({"": DehydrateChoice.AUTO}, output_file=tmp_path / "out.yaml").dehydrate(data) + + nested_ref = result["implementation"]["graph"]["tasks"]["nested"]["componentRef"] + assert nested_ref == {"url": "file://./subgraphs/nested_subgraph_0.yaml"} + + subgraph_file = tmp_path / "subgraphs" / "nested_subgraph_0.yaml" + subgraph = yaml.safe_load(subgraph_file.read_text(encoding="utf-8")) + assert subgraph["implementation"]["graph"]["tasks"]["leaf"]["componentRef"] == { + "url": "file://./../components/inner_leaf.yaml" + } + assert yaml.safe_load((tmp_path / "components" / "inner_leaf.yaml").read_text(encoding="utf-8"))["name"] == "Inner Leaf" + + +def test_pipeline_dehydrator_exports_jinja2_template_and_config(tmp_path: Path) -> None: + subgraph_spec = {"name": "Reusable", "implementation": {"graph": {"tasks": {}}}} + data = { + "name": "Pipeline", + "inputs": [{"name": "Model Name", "type": "string", "default": "tiny"}], + "implementation": { + "graph": { + "tasks": { + "nested": {"componentRef": {"name": "Reusable", "digest": "digest-sub", "spec": subgraph_spec}} + } + } + }, + } + + result = PipelineDehydrator(output_file=tmp_path / "config.yaml").export_to_jinja2( + data, + tmp_path / "config.yaml", + tmp_path / "pipeline.yaml.j2", + ) + + assert isinstance(result, Jinja2ExportResult) + assert result.subtemplates_count == 1 + assert result.top_level_params_count == 1 + config = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert config == {"template_file": "pipeline.yaml.j2", "model_name": "tiny"} + template = (tmp_path / "pipeline.yaml.j2").read_text(encoding="utf-8") + assert "{{ model_name }}" in template + assert "{% include 'pipeline_subtemplate_0.yaml.j2' %}" in template + assert result.subtemplate_paths[0].name == "pipeline_subtemplate_0.yaml.j2" + + +def test_pipeline_dehydrator_uses_uri_hooks_for_read_write_and_extracted_components() -> None: + input_data = _pipeline({"leaf": _task("Remote Leaf", "digest-remote")}) + sources = {"mem://bucket/input.yaml": utils.dump_yaml(input_data)} + writes: dict[str, str] = {} + + def reader(_hydrator, uri, _context): + return sources[uri] + + def writer(_hydrator, uri, content, _context): + writes[uri] = content + + result = PipelineDehydrator( + {"": DehydrateChoice.FILE}, + uri_readers={"mem": reader}, + uri_writers={"mem": writer}, + ).dehydrate_file("mem://bucket/input.yaml", "mem://bucket/out/pipeline.yaml") + + assert result["implementation"]["graph"]["tasks"]["leaf"]["componentRef"] == { + "url": "mem://bucket/out/components/remote_leaf.yaml" + } + assert yaml.safe_load(writes["mem://bucket/out/components/remote_leaf.yaml"])["name"] == "Remote Leaf" + assert yaml.safe_load(writes["mem://bucket/out/pipeline.yaml"]) == result + + +def test_pipeline_dehydrator_helper_exports_are_importable() -> None: + assert _extract_input_defaults({"inputs": {"User Name": {"default": "Ada"}}}) == {"user_name": "Ada"} + assert _build_subgraph_processing_queue(_pipeline({}))[0] == (0, "Pipeline") diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py new file mode 100644 index 0000000..26b6530 --- /dev/null +++ b/tests/test_pipeline_runs_cli.py @@ -0,0 +1,2331 @@ +from __future__ import annotations + +import copy +import json +from contextlib import nullcontext +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml + +from tangle_cli import cli, pipeline_run_manager, pipeline_runs_cli +from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks +from tangle_cli.pipeline_run_manager import ( + PipelineRunContext, + PipelineRunHooks, + PipelineRunManager, + PipelineRunError, + PipelineWaitOutcome, + PipelineWaitPoll, +) + + +def run_app(app, args: list[str]) -> None: + try: + app(args) + except SystemExit as exc: + if exc.code not in (0, None): + raise + + +def _write_pipeline(path: Path) -> Path: + path.write_text( + yaml.safe_dump( + { + "name": "Demo Pipeline", + "inputs": [ + {"name": "query", "type": "String", "default": "default"}, + {"name": "required", "type": "String"}, + ], + "implementation": {"graph": {"tasks": {}}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + return path + + +class FakeClient: + def __init__(self) -> None: + self.base_url = "https://tangle.example" + self.created: list[Any] = [] + self.cancelled: list[str] = [] + self.annotation_sets: list[tuple[str, str, Any]] = [] + self.annotation_deletes: list[tuple[str, str]] = [] + self.get_calls: list[dict[str, Any]] = [] + self.list_calls: list[dict[str, Any]] = [] + + def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: + self.created.append(body) + return {"id": "run-1", "root_execution_id": "exec-1"} + + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + self.get_calls.append({"id": id, "include_execution_stats": include_execution_stats}) + return { + "id": id, + "root_execution_id": "exec-1", + "execution_summary": {"has_ended": True}, + "execution_status_stats": {"SUCCEEDED": 1}, + } + + def get_run_details(self, run_id: str, **kwargs: Any) -> dict[str, Any]: + return {"run": {"id": run_id}, "kwargs": kwargs} + + def pipeline_runs_cancel(self, id: str) -> None: + self.cancelled.append(id) + return None + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"child_execution_status_stats": {id: {"RUNNING": 1}}} + + def executions_container_log(self, id: str) -> dict[str, Any]: + return {"log_text": f"logs for {id}\n"} + + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + return {"pipeline_runs": [{"id": "run-1"}], "next_page_token": None} + + def users_me(self) -> SimpleNamespace: + return SimpleNamespace(id="alice@example.com") + + def pipeline_runs_annotations(self, id: str) -> dict[str, Any]: + return {"owner": "alice", "id": id} + + def pipeline_runs_put_annotations(self, id: str, key: str, value: Any = None) -> None: + self.annotation_sets.append((id, key, value)) + + def pipeline_runs_delete_annotations(self, id: str, key: str) -> None: + self.annotation_deletes.append((id, key)) + + def get_run_pipeline_spec(self, run_id: str) -> Any: + return SimpleNamespace( + raw={"componentRef": {"spec": {"name": "Exported", "implementation": {"graph": {"tasks": {}}}}}} + ) + + def get_component_spec(self, digest: str) -> dict[str, Any]: + return {"name": digest} + + +def test_pipeline_runs_help_exposes_run_commands_not_local_pipeline_commands(capsys): + app = cli.build_app() + + run_app(app, ["sdk", "--help"]) + assert "pipeline-runs" in capsys.readouterr().out + + run_app(app, ["sdk", "pipeline-runs", "--help"]) + output = capsys.readouterr().out + for command in ( + "submit", + "details", + "status", + "graph-state", + "cancel", + "wait", + "logs", + "search", + "annotations", + "export", + ): + assert command in output + assert "validate" not in output + assert "diagram" not in output + + run_app(app, ["sdk", "pipeline-runs", "submit", "--help"]) + assert "--log-type" in capsys.readouterr().out + + +def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path, capsys): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipeline-runs", + "submit", + str(pipeline_path), + "--no-hydrate", + "--arg", + "required=value", + "--annotation", + "team=oss", + ], + ) + + result = json.loads(capsys.readouterr().out) + assert result == {"id": "run-1", "root_execution_id": "exec-1"} + assert fake_client.created[0]["annotations"] == {"team": "oss"} + root_task = fake_client.created[0]["root_task"] + assert root_task["componentRef"]["spec"]["name"] == "Demo Pipeline" + assert root_task["arguments"] == {"query": "default", "required": "value"} + + +def test_pipeline_runs_submit_accepts_export_config_args_and_hydrate(monkeypatch, tmp_path: Path): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + config = tmp_path / "pipeline.config.yaml" + config.write_text( + yaml.safe_dump( + {"pipeline_path": str(pipeline_path), "args": {"required": "value", "query": "custom"}, "hydrate": True}, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", "--config", str(config)]) + + assert fake_client.created[0]["root_task"]["arguments"] == {"query": "custom", "required": "value"} + + +def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_path: Path, capsys): + pipeline_path = tmp_path / "pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + { + "name": "Demo Pipeline", + "_source_dir": "/tmp/private", + "implementation": { + "graph": { + "tasks": { + "task": { + "arguments": {"config": {"_meta": {"mode": "keep"}}}, + "componentRef": { + "name": "text-component", + "text": "name: Text Component\n_source_dir: /tmp/private\nimplementation:\n container:\n image: busybox\n", + } + } + } + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipeline-runs", + "submit", + str(pipeline_path), + "--no-hydrate", + "--dry-run", + ], + ) + + payload = json.loads(capsys.readouterr().out) + assert fake_client.created == [] + spec = payload["root_task"]["componentRef"]["spec"] + assert "_source_dir" not in spec + task = spec["implementation"]["graph"]["tasks"]["task"] + assert task["arguments"]["config"] == {"_meta": {"mode": "keep"}} + task_ref = task["componentRef"] + assert "text" not in task_ref + assert task_ref["spec"]["name"] == "Text Component" + assert "_source_dir" not in task_ref["spec"] + + +def test_pipeline_runs_submit_with_hydrate_logs_progress( + monkeypatch, + tmp_path: Path, + capsys, +): + (tmp_path / "component.yaml").write_text( + yaml.safe_dump( + { + "name": "Local Component", + "implementation": {"container": {"image": "busybox"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + pipeline_path = tmp_path / "pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + { + "name": "Demo Pipeline", + "implementation": { + "graph": { + "tasks": { + "task": {"componentRef": {"url": "file://./component.yaml"}} + } + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path)]) + + captured = capsys.readouterr() + assert json.loads(captured.out) == {"id": "run-1", "root_execution_id": "exec-1"} + assert fake_client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Demo Pipeline" + assert "Loading component from file URL" in captured.err + assert "✅ Loaded component" in captured.err + + +def test_pipeline_runs_hydrate_logs_progress_when_verbose_false( + monkeypatch, + tmp_path: Path, + capsys, +): + monkeypatch.setenv("TANGLE_VERBOSE", "0") + (tmp_path / "component.yaml").write_text( + yaml.safe_dump( + { + "name": "Local Component", + "implementation": {"container": {"image": "busybox"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + pipeline_path = tmp_path / "pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + { + "name": "Demo Pipeline", + "implementation": { + "graph": { + "tasks": { + "task": {"componentRef": {"url": "file://./component.yaml"}} + } + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--dry-run"]) + + captured = capsys.readouterr() + assert json.loads(captured.out)["root_task"]["componentRef"]["spec"]["name"] == "Demo Pipeline" + assert "Loading component from file URL" in captured.err + assert "✅ Loaded component" in captured.err + assert "[verbose]" not in captured.err + + +def test_pipeline_runs_submit_log_type_file_captures_progress( + monkeypatch, + tmp_path: Path, + capsys, +): + (tmp_path / "component.yaml").write_text( + yaml.safe_dump( + { + "name": "Local Component", + "implementation": {"container": {"image": "busybox"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + pipeline_path = tmp_path / "pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + { + "name": "Demo Pipeline", + "implementation": { + "graph": { + "tasks": { + "task": {"componentRef": {"url": "file://./component.yaml"}} + } + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipeline-runs", + "submit", + str(pipeline_path), + "--dry-run", + "--log-type", + "file", + ], + ) + + captured = capsys.readouterr() + assert json.loads(captured.out)["root_task"]["componentRef"]["spec"]["name"] == "Demo Pipeline" + assert "Logs written to:" in captured.err + log_path = Path(captured.err.split("Logs written to:", 1)[1].strip()) + try: + log_text = log_path.read_text(encoding="utf-8") + finally: + log_path.unlink(missing_ok=True) + assert "Loading component from file URL" in log_text + assert "✅ Loaded component" in log_text + + +def test_pipeline_runs_submit_log_type_none_suppresses_progress( + monkeypatch, + tmp_path: Path, + capsys, +): + (tmp_path / "component.yaml").write_text( + yaml.safe_dump( + { + "name": "Local Component", + "implementation": {"container": {"image": "busybox"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + pipeline_path = tmp_path / "pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + { + "name": "Demo Pipeline", + "implementation": { + "graph": { + "tasks": { + "task": {"componentRef": {"url": "file://./component.yaml"}} + } + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipeline-runs", + "submit", + str(pipeline_path), + "--dry-run", + "--log-type", + "none", + ], + ) + + captured = capsys.readouterr() + assert json.loads(captured.out)["root_task"]["componentRef"]["spec"]["name"] == "Demo Pipeline" + assert captured.err == "" + + +def test_pipeline_runs_config_base_url_suppresses_ambient_credentials(monkeypatch, tmp_path: Path): + calls: list[dict[str, Any]] = [] + fake_client = FakeClient() + + def fake_client_from_options(**kwargs: Any) -> FakeClient: + calls.append(kwargs) + return fake_client + + config = tmp_path / "config.yaml" + config.write_text("base_url: https://api.test\ntoken: explicit\n", encoding="utf-8") + monkeypatch.setenv("TANGLE_API_TOKEN", "ambient") + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", fake_client_from_options) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "status", "run-1", "--config", str(config)]) + + assert calls[0]["base_url"] == "https://api.test" + assert calls[0]["token"] == "explicit" + assert calls[0]["include_env_credentials"] is False + + +def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: Path, capsys): + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "details", "run-1", "--include-annotations"]) + assert json.loads(capsys.readouterr().out)["kwargs"] == { + "include_annotations": True, + "include_execution_state": False, + } + + run_app(app, ["sdk", "pipeline-runs", "status", "run-1"]) + assert json.loads(capsys.readouterr().out)["status"] == "SUCCEEDED" + + run_app(app, ["sdk", "pipeline-runs", "graph-state", "exec-1"]) + assert json.loads(capsys.readouterr().out)["child_execution_status_stats"] == {"exec-1": {"RUNNING": 1}} + + run_app(app, ["sdk", "pipeline-runs", "cancel", "run-1"]) + assert json.loads(capsys.readouterr().out) == {"cancelled": True, "id": "run-1"} + assert fake_client.cancelled == ["run-1"] + + run_app(app, ["sdk", "pipeline-runs", "logs", "exec-1"]) + assert capsys.readouterr().out == "logs for exec-1\n" + + run_app(app, ["sdk", "pipeline-runs", "search", "demo", "--filter-query", "status:running"]) + assert json.loads(capsys.readouterr().out)["pipeline_runs"] == [{"id": "run-1"}] + assert fake_client.list_calls[-1]["filter"] == "demo" + assert fake_client.list_calls[-1]["filter_query"] == "status:running" + + run_app(app, ["sdk", "pipeline-runs", "annotations", "list", "run-1"]) + annotation_result = json.loads(capsys.readouterr().out) + assert annotation_result["annotations"]["owner"] == "alice" + assert annotation_result["count"] == 2 + + run_app(app, ["sdk", "pipeline-runs", "annotations", "set", "run-1", "owner", "bob"]) + assert fake_client.annotation_sets == [("run-1", "owner", "bob")] + + run_app(app, ["sdk", "pipeline-runs", "annotations", "set", "run-1", "flag"]) + assert fake_client.annotation_sets[-1] == ("run-1", "flag", None) + + run_app(app, ["sdk", "pipeline-runs", "annotations", "delete", "run-1", "owner"]) + assert fake_client.annotation_deletes == [("run-1", "owner")] + + output = tmp_path / "export.yaml" + run_app(app, ["sdk", "pipeline-runs", "export", "run-1", "--output", str(output)]) + assert yaml.safe_load(output.read_text(encoding="utf-8"))["name"] == "Exported" + + +def test_pipeline_runs_export_writes_execution_config(monkeypatch, tmp_path: Path): + class ExportClient(FakeClient): + def get_run_pipeline_spec(self, run_id: str) -> Any: + return SimpleNamespace( + raw={ + "componentRef": { + "spec": {"name": "Exported", "implementation": {"graph": {"tasks": {}}}} + } + }, + arguments={"query": "boots", "limit": 10}, + ) + + app = cli.build_app() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: ExportClient()) + + output = tmp_path / "export.yaml" + run_app(app, ["sdk", "pipeline-runs", "export", "run-1", "--output", str(output)]) + + config = yaml.safe_load((tmp_path / "export.config.yaml").read_text(encoding="utf-8")) + assert config == {"pipeline_path": "export.yaml", "args": {"query": "boots", "limit": 10}} + + +def test_pipeline_runs_export_requires_output_for_dehydration() -> None: + manager = PipelineRunManager(client=FakeClient()) + + with pytest.raises(PipelineRunError, match="--dehydrate requires --output"): + manager.export_run("run-1", dehydrate=True) + + +def test_pipeline_runs_export_rejects_empty_pipeline_spec() -> None: + class ExportClient(FakeClient): + def get_run_pipeline_spec(self, run_id: str) -> Any: + return SimpleNamespace(raw={"componentRef": {"spec": {}}}, arguments={}) + + manager = PipelineRunManager(client=ExportClient()) + + with pytest.raises(PipelineRunError, match="Pipeline spec for run run-1 is not exportable"): + manager.export_run("run-1") + + +def test_pipeline_runs_export_dehydrator_inherits_manager_logger(monkeypatch, tmp_path: Path) -> None: + class ExportClient(FakeClient): + def get_run_pipeline_spec(self, run_id: str) -> Any: + return SimpleNamespace( + raw={"componentRef": {"spec": {"name": "Exported", "implementation": {"graph": {"tasks": {}}}}}}, + arguments={}, + ) + + captured: dict[str, Any] = {} + + class FakeDehydrator: + def __init__(self, **kwargs: Any) -> None: + captured.update(kwargs) + + def dehydrate(self, spec: dict[str, Any]) -> dict[str, Any]: + return spec + + logger = object() + monkeypatch.setattr(pipeline_run_manager, "PipelineDehydrator", FakeDehydrator) + + PipelineRunManager(client=ExportClient(), logger=logger).export_run( + "run-1", + output=tmp_path / "export.yaml", + dehydrate=True, + ) + + assert captured["logger"] is logger + + +def test_pipeline_runs_export_can_dehydrate_pipeline(monkeypatch, tmp_path: Path): + class ExportClient(FakeClient): + def __init__(self) -> None: + super().__init__() + self.spec_lookups: list[str] = [] + + def get_run_pipeline_spec(self, run_id: str) -> Any: + return SimpleNamespace( + raw={ + "componentRef": { + "spec": { + "name": "Exported", + "implementation": { + "graph": { + "tasks": { + "step": { + "componentRef": { + "name": "Step", + "digest": "digest-1", + "spec": {"name": "Step", "version": "1.0.0"}, + } + } + } + } + }, + } + } + }, + arguments={"query": "boots"}, + ) + + def get_component_spec(self, digest: str) -> dict[str, Any]: + self.spec_lookups.append(digest) + return {"name": "published"} + + app = cli.build_app() + fake_client = ExportClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + + output = tmp_path / "export-dehydrated.yaml" + run_app(app, ["sdk", "pipeline-runs", "export", "run-1", "--output", str(output), "--dehydrate"]) + + exported = yaml.safe_load(output.read_text(encoding="utf-8")) + component_ref = exported["implementation"]["graph"]["tasks"]["step"]["componentRef"] + assert component_ref == {"digest": "digest-1"} + config = yaml.safe_load((tmp_path / "export-dehydrated.config.yaml").read_text(encoding="utf-8")) + assert config == { + "pipeline_path": "export-dehydrated.yaml", + "hydrate": True, + "args": {"query": "boots"}, + } + assert fake_client.spec_lookups == ["digest-1"] + + +def test_pipeline_runs_export_config_dehydrate_can_be_disabled(monkeypatch, tmp_path: Path): + class ExportClient(FakeClient): + def __init__(self) -> None: + super().__init__() + self.spec_lookups: list[str] = [] + + def get_run_pipeline_spec(self, run_id: str) -> Any: + return SimpleNamespace( + raw={ + "componentRef": { + "spec": { + "name": "Exported", + "implementation": { + "graph": { + "tasks": { + "step": { + "componentRef": { + "name": "Step", + "digest": "digest-1", + "spec": {"name": "Step", "version": "1.0.0"}, + } + } + } + } + }, + } + } + }, + arguments={"query": "boots"}, + ) + + def get_component_spec(self, digest: str) -> dict[str, Any]: + self.spec_lookups.append(digest) + return {"name": "published"} + + app = cli.build_app() + fake_client = ExportClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + config = tmp_path / "export.config.yaml" + config.write_text("dehydrate: true\n", encoding="utf-8") + + output = tmp_path / "export.yaml" + run_app( + app, + [ + "sdk", + "pipeline-runs", + "export", + "run-1", + "--output", + str(output), + "--config", + str(config), + "--no-dehydrate", + ], + ) + + exported = yaml.safe_load(output.read_text(encoding="utf-8")) + component_ref = exported["implementation"]["graph"]["tasks"]["step"]["componentRef"] + assert "spec" in component_ref + assert fake_client.spec_lookups == [] + + +def test_pipeline_runs_rich_search_builds_filters_and_formats_pages() -> None: + class SearchClient(FakeClient): + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + if kwargs.get("page_token") is None: + return { + "pipeline_runs": [ + { + "id": "run-abcdef123456", + "pipeline_name": "Orders Pipeline", + "created_by": "alice@example.com", + "created_at": "2026-06-13T12:34:56Z", + } + ], + "next_page_token": "page-2", + } + return { + "pipeline_runs": [ + { + "id": "run-fedcba654321", + "pipeline_name": "Orders Pipeline Retry", + "created_by": "alice@example.com", + "created_at": "2026-06-13T13:34:56Z", + } + ], + "next_page_token": None, + } + + client = SearchClient() + manager = PipelineRunManager(client=client) + + result = manager.search_pipeline_runs( + name="Orders", + created_by="me", + annotations={"team": "search", "debug": None}, + start_date="2026-06-13T00:00:00Z", + end_date="2026-06-14T00:00:00Z", + limit=2, + ) + + assert result["count"] == 2 + assert result["runs"][0]["run_url"] == "https://tangle.example/runs/run-abcdef123456" + assert result["pages"][0]["next_page_token"] == "page-2" + assert "Pipeline Run Search Results" in result["cli_table"] + filter_query = json.loads(client.list_calls[0]["filter_query"]) + assert filter_query == { + "and": [ + {"value_contains": {"key": "system/pipeline_run.name", "value_substring": "Orders"}}, + {"value_equals": {"key": "system/pipeline_run.created_by", "value": "alice@example.com"}}, + {"value_contains": {"key": "team", "value_substring": "search"}}, + {"key_exists": {"key": "debug"}}, + { + "time_range": { + "key": "system/pipeline_run.date.created_at", + "start_time": "2026-06-13T00:00:00Z", + "end_time": "2026-06-14T00:00:00Z", + } + }, + ] + } + assert client.list_calls[0]["include_pipeline_names"] is True + assert client.list_calls[1]["page_token"] == "page-2" + + +def test_pipeline_runs_search_cli_table_output(monkeypatch, capsys) -> None: + class SearchClient(FakeClient): + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + return { + "pipeline_runs": [ + { + "id": "run-1", + "pipeline_name": "Demo", + "created_by": "alice@example.com", + "created_at": "2026-06-13T12:34:56Z", + } + ], + "next_page_token": None, + } + + fake_client = SearchClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipeline-runs", + "search", + "--name", + "Demo", + "--annotation", + "team=search", + "--output", + "table", + ], + ) + + output = capsys.readouterr().out + assert "Pipeline Run Search Results" in output + assert "https://tangle.example/runs/run-1" in output + assert json.loads(fake_client.list_calls[0]["filter_query"])["and"][0] == { + "value_contains": {"key": "system/pipeline_run.name", "value_substring": "Demo"} + } + + +def test_pipeline_runs_graph_state_returns_plain_generated_response() -> None: + class GraphStateClient: + def executions_graph_execution_state(self, id: str) -> Any: + assert id == "exec-1" + return SimpleNamespace( + status_totals={"SUCCEEDED": 1}, + child_execution_status_stats={"child-1": {"RUNNING": 2}}, + ) + + manager = PipelineRunManager(client=GraphStateClient()) + + assert manager.graph_state("exec-1") == { + "status_totals": {"SUCCEEDED": 1}, + "child_execution_status_stats": {"child-1": {"RUNNING": 2}}, + } + + +def test_pipeline_runs_details_and_graph_state_helpers() -> None: + manager = PipelineRunManager(client=FakeClient()) + + details = manager.get_run_details("run-1", include_annotations=True, include_execution_state=True) + assert details == {"run": {"id": "run-1"}, "kwargs": { + "include_annotations": True, + "include_execution_state": True, + }} + + graph = manager.graph_state_output(["run-1"], timeout=1) + assert graph == { + "results": [ + { + "run_id": "run-1", + "root_execution_id": "exec-1", + "status_totals": None, + "failed_execution_ids": None, + "per_execution": None, + "error": None, + } + ] + } + + +def _write_submit_local_from_python_pipeline( + project_dir: Path, + python_file: str, + *, + resolve_root: str | None = None, +) -> Path: + gen_config = { + "file": python_file, + "output_folder": "./generated", + } + if resolve_root is not None: + gen_config["resolve_root"] = resolve_root + (project_dir / "components.resolve.yaml").write_text( + yaml.safe_dump({"generated": {"local_from_python": gen_config}}, sort_keys=False), + encoding="utf-8", + ) + pipeline_path = project_dir / "pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + { + "name": "Submit Pipeline", + "implementation": { + "graph": { + "tasks": { + "generated": { + "componentRef": {"url": "resolve://./components.resolve.yaml#generated"} + } + } + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + return pipeline_path + + +def test_pipeline_runs_submit_refuses_untrusted_local_from_python( + monkeypatch, + tmp_path: Path, +) -> None: + from tangle_cli.component_generator import ComponentGenerator + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "evil.py" + outside_python.write_text("raise RuntimeError('must not execute')\n", encoding="utf-8") + pipeline_path = _write_submit_local_from_python_pipeline(project_dir, str(outside_python)) + + def fake_regenerate_yaml(self, **kwargs): + raise AssertionError("untrusted local_from_python must be blocked before generation") + + monkeypatch.setattr(ComponentGenerator, "regenerate_yaml", fake_regenerate_yaml) + manager = PipelineRunManager(client=FakeClient()) + + with pytest.raises(PipelineRunError, match="Refusing to execute untrusted local_from_python source"): + manager.submit_pipeline(pipeline_path) + + +def test_pipeline_runs_submit_ignores_untrusted_resolve_root_for_python_trust( + monkeypatch, + tmp_path: Path, +) -> None: + from tangle_cli.component_generator import ComponentGenerator + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "evil.py" + outside_python.write_text("raise RuntimeError('must not execute')\n", encoding="utf-8") + pipeline_path = _write_submit_local_from_python_pipeline( + project_dir, + str(outside_python), + resolve_root=str(outside_dir), + ) + + def fake_regenerate_yaml(self, **kwargs): + raise AssertionError("untrusted resolve_root must not authorize execution") + + monkeypatch.setattr(ComponentGenerator, "regenerate_yaml", fake_regenerate_yaml) + manager = PipelineRunManager(client=FakeClient()) + + with pytest.raises(PipelineRunError, match="Refusing to execute untrusted local_from_python source"): + manager.submit_pipeline(pipeline_path) + + +def test_pipeline_runs_submit_trusted_hydration_allows_untrusted_local_from_python( + monkeypatch, + tmp_path: Path, + capsys, +) -> None: + from tangle_cli.component_generator import ComponentGenerator + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "component.py" + outside_python.write_text("# trusted by explicit override\n", encoding="utf-8") + pipeline_path = _write_submit_local_from_python_pipeline(project_dir, str(outside_python)) + regenerated: list[Path] = [] + + def fake_regenerate_yaml(self, **kwargs): + regenerated.append(kwargs["python_file"]) + kwargs["output_path"].write_text( + "name: Submit Generated Component\nimplementation:\n container:\n image: busybox\n", + encoding="utf-8", + ) + return True + + fake_client = FakeClient() + monkeypatch.setattr(ComponentGenerator, "regenerate_yaml", fake_regenerate_yaml) + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--trusted-hydration"]) + + assert json.loads(capsys.readouterr().out)["id"] == "run-1" + assert regenerated == [outside_python.resolve()] + submitted_task = fake_client.created[0]["root_task"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["generated"] + assert submitted_task["componentRef"]["name"] == "Submit Generated Component" + + +def test_pipeline_runs_build_submit_body_from_prepared_spec_and_run_name_template() -> None: + class Hooks(PipelineRunHooks): + def prepare_pipeline_spec(self, pipeline_spec, *, pipeline_path, run_args, hydrate): + prepared = dict(pipeline_spec) + prepared.setdefault("metadata", {}).setdefault("annotations", {})["prepared"] = "yes" + return prepared + + def prepare_run_arguments(self, pipeline_spec, run_args): + merged = dict(run_args or {}) + merged["timestamp"] = "2026-06-13" + return merged + + manager = PipelineRunManager(client=FakeClient(), hooks=Hooks()) + body = manager.build_submit_body_from_spec( + { + "name": "Original", + "inputs": [{"name": "timestamp", "type": "String"}], + "metadata": {"annotations": {"run-name-template": "run-${arguments.timestamp}"}}, + "implementation": {"graph": {"tasks": {}}}, + }, + run_args={}, + annotations={"team": "oss"}, + hydrate=False, + ) + + spec = body["root_task"]["componentRef"]["spec"] + assert spec["name"] == "run-2026-06-13" + assert spec["metadata"]["annotations"]["prepared"] == "yes" + assert body["root_task"]["arguments"] == {"timestamp": "2026-06-13"} + assert body["annotations"] == {"team": "oss"} + + +def test_pipeline_runs_submit_error_hook_gets_context() -> None: + class FailingClient(FakeClient): + def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: + raise RuntimeError("boom") + + errors = [] + + class Hooks(PipelineRunHooks): + def on_submit_error(self, error, *, context): + errors.append((str(error), context.run_name, context.pipeline_spec["name"])) + + manager = PipelineRunManager(client=FailingClient(), hooks=Hooks()) + + with pytest.raises(RuntimeError, match="boom"): + manager.submit_pipeline_spec( + {"name": "Explodes", "implementation": {"graph": {"tasks": {}}}}, + hydrate=False, + ) + + assert errors == [("boom", "Explodes", "Explodes")] + + +def test_pipeline_runs_wait_uses_graph_state_and_poll_hooks() -> None: + events = [] + + class GraphClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-graph", + "execution_status_stats": {"RUNNING": 1}, + } + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"status_totals": {"SUCCEEDED": 2}} + + class Hooks(PipelineRunHooks): + def before_wait(self, context): + events.append(("before_wait", context.run_id)) + + def after_poll(self, poll, context): + events.append(("after_poll", poll.status_counts, poll.total, poll.terminal)) + + def on_terminal(self, poll, context): + events.append(("terminal", poll.status)) + + def after_wait_context(self, result, context): + events.append(("after_wait", result["status"])) + + manager = PipelineRunManager(client=GraphClient(), hooks=Hooks()) + + result = manager.wait_for_completion( + "run-graph", + max_wait=None, + poll_interval=1, + use_graph_state=True, + ) + + assert result["status"] == "SUCCEEDED" + assert events == [ + ("before_wait", "run-graph"), + ("after_poll", {"SUCCEEDED": 2}, 2, True), + ("terminal", "SUCCEEDED"), + ("after_wait", "SUCCEEDED"), + ] + + +def test_pipeline_runs_wait_graph_state_treats_canceled_spelling_as_terminal() -> None: + class CanceledGraphClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-canceled", + "execution_status_stats": {"RUNNING": 1}, + } + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"status_totals": {"CANCELED": 1}} + + manager = PipelineRunManager(client=CanceledGraphClient()) + + result = manager.wait_for_completion( + "run-canceled", + max_wait=0, + poll_interval=1, + use_graph_state=True, + ) + + assert result == { + "run": { + "id": "run-canceled", + "root_execution_id": "exec-canceled", + "execution_status_stats": {"RUNNING": 1}, + }, + "status": "CANCELED", + "timed_out": False, + } + + +def test_pipeline_runs_wait_graph_state_treats_invalid_as_terminal() -> None: + class InvalidGraphClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-invalid", + "execution_status_stats": {"RUNNING": 1}, + } + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"status_totals": {"INVALID": 1}} + + manager = PipelineRunManager(client=InvalidGraphClient()) + + result = manager.wait_for_completion( + "run-invalid", + max_wait=0, + poll_interval=1, + use_graph_state=True, + ) + + assert result == { + "run": { + "id": "run-invalid", + "root_execution_id": "exec-invalid", + "execution_status_stats": {"RUNNING": 1}, + }, + "status": "INVALID", + "timed_out": False, + } + + +def test_pipeline_runs_wait_outcome_records_success_failure_timeout_and_early_exit() -> None: + manager = PipelineRunManager(client=FakeClient()) + scenarios = [ + ( + PipelineWaitPoll("run-1", {}, "SUCCEEDED", {"SUCCEEDED": 1}, 1, True), + {}, + True, + False, + False, + ), + ( + PipelineWaitPoll("run-1", {}, "SKIPPED", {"SUCCEEDED": 9, "SKIPPED": 1}, 10, True), + {}, + True, + False, + False, + ), + ( + PipelineWaitPoll("run-1", {}, "CANCELLED", {"SUCCEEDED": 8, "CANCELLED": 2}, 10, True), + {}, + False, + False, + False, + ), + ( + PipelineWaitPoll("run-1", {}, "FAILED", {"SUCCEEDED": 7, "FAILED": 3}, 10, True), + {}, + False, + False, + False, + ), + ( + PipelineWaitPoll("run-1", {}, "RUNNING", {"RUNNING": 1}, 1, False), + {"max_wait": 0}, + None, + True, + False, + ), + ( + PipelineWaitPoll("run-1", {}, "FAILED", {"FAILED": 1}, 1, False), + {"exit_on_first_failure": True}, + False, + False, + True, + ), + ] + for poll, options, expected_success, expected_timeout, expected_early_exit in scenarios: + context = PipelineRunContext(run_id="run-1") + manager._poll_run_status = lambda *args, **kwargs: poll # type: ignore[method-assign] + + manager.wait_for_completion( + "run-1", + max_wait=options.get("max_wait", 10), + poll_interval=0, + allow_zero_poll_interval=True, + context=context, + exit_on_first_failure=bool(options.get("exit_on_first_failure", False)), + ) + + assert context.wait_outcome is not None + assert context.wait_outcome.success is expected_success + assert context.wait_outcome.timed_out is expected_timeout + assert context.wait_outcome.early_exit is expected_early_exit + + +def test_pipeline_runs_wait_outcome_zero_total_early_exit_is_failure_not_exited_early() -> None: + class ZeroTaskExitHooks(PipelineRunHooks): + def should_exit_early( + self, + poll: PipelineWaitPoll, + context: PipelineRunContext, + ) -> bool: + del context + return poll.total == 0 + + manager = PipelineRunManager(client=FakeClient(), hooks=ZeroTaskExitHooks()) + context = PipelineRunContext(run_id="run-1") + manager._poll_run_status = ( # type: ignore[method-assign] + lambda *args, **kwargs: PipelineWaitPoll("run-1", {}, "UNKNOWN", {}, 0, False) + ) + + manager.wait_for_completion( + "run-1", + max_wait=10, + poll_interval=0, + allow_zero_poll_interval=True, + context=context, + ) + + assert context.wait_outcome is not None + assert context.wait_outcome.success is False + assert context.wait_outcome.timed_out is False + assert context.wait_outcome.early_exit is False + + +def test_pipeline_runs_wait_outcome_from_wait_result_derives_counts_from_status_counts() -> None: + outcome = PipelineWaitOutcome.from_wait_result( + {"status": "FAILED", "timed_out": False}, + {"status_counts": {"FAILED": 1, "SYSTEM_ERROR": 1}}, + ) + + assert outcome.success is False + assert outcome.failed_count == 1 + assert outcome.error_count == 1 + + +def test_pipeline_runs_wait_outcome_ended_without_counts_is_unknown() -> None: + assert PipelineWaitOutcome(status="ENDED").success is None + + outcome = PipelineWaitOutcome.from_wait_result({"status": "ENDED", "timed_out": False}) + + assert outcome.success is None + + +def test_pipeline_runs_wait_outcome_ended_uses_reliable_counts() -> None: + success = PipelineWaitOutcome.from_wait_result( + {"status": "ENDED", "timed_out": False}, + {"status_counts": {"SUCCEEDED": 2, "SKIPPED": 1}}, + ) + failure = PipelineWaitOutcome.from_wait_result( + {"status": "ENDED", "timed_out": False}, + {"status_counts": {"SUCCEEDED": 2, "FAILED": 1}}, + ) + + assert success.success is True + assert failure.success is False + + +def test_pipeline_runs_wait_outcome_records_ended_without_counts_as_unknown() -> None: + class EndedWithoutStatsClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return {"id": id, "execution_summary": {"has_ended": True}} + + manager = PipelineRunManager(client=EndedWithoutStatsClient()) + context = PipelineRunContext(run_id="run-ended") + + result = manager.wait_for_completion( + "run-ended", + max_wait=10, + poll_interval=0, + allow_zero_poll_interval=True, + context=context, + ) + + assert result["status"] == "ENDED" + assert context.wait_outcome is not None + assert context.wait_outcome.success is None + + +def test_pipeline_runs_graph_state_counts_supports_mapping_like_objects() -> None: + assert PipelineRunManager.status_counts_from_graph_state( + SimpleNamespace(status_totals={"SUCCEEDED": 1}) + ) == {"SUCCEEDED": 1} + + +def test_pipeline_runs_wait_can_poll_execution_root_via_hooks() -> None: + events = [] + + class ExecutionClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + raise AssertionError("execution-rooted polling should not fetch a run") + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + assert id == "exec-root" + return {"status_totals": {"SUCCEEDED": 2}} + + class Hooks(PipelineRunHooks): + def poll_run_snapshot(self, manager, run_id, context): + return {"id": run_id, "root_execution_id": context.root_execution_id} + + def after_poll(self, poll, context): + events.append((poll.status_counts, poll.total, poll.terminal)) + + manager = PipelineRunManager(client=ExecutionClient(), hooks=Hooks()) + context = PipelineRunContext( + run_id="exec-root", + root_execution_id="exec-root", + ) + + result = manager.wait_for_completion( + "exec-root", + max_wait=None, + poll_interval=1, + use_graph_state=True, + context=context, + ) + + assert result["status"] == "SUCCEEDED" + assert events == [({"SUCCEEDED": 2}, 2, True)] + + +def test_pipeline_runs_wait_poll_error_hook_can_retry() -> None: + class FlakyGraphClient(FakeClient): + def __init__(self) -> None: + super().__init__() + self.calls = 0 + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("transient") + return {"status_totals": {"SUCCEEDED": 1}} + + class Hooks(PipelineRunHooks): + def on_poll_error(self, error, context): + assert str(error) == "transient" + return 0 + + manager = PipelineRunManager(client=FlakyGraphClient(), hooks=Hooks()) + + result = manager.wait_for_completion( + "run-graph", + max_wait=None, + poll_interval=1, + use_graph_state=True, + ) + + assert result["status"] == "SUCCEEDED" + + +def test_pipeline_runs_wait_poll_error_hook_respects_max_wait() -> None: + class FailingGraphClient(FakeClient): + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + raise RuntimeError("persistent poll failure") + + errors = [] + + class Hooks(PipelineRunHooks): + def on_poll_error(self, error, context): + errors.append(str(error)) + return 0 + + manager = PipelineRunManager(client=FailingGraphClient(), hooks=Hooks()) + + with pytest.raises(PipelineRunError, match="Timed out waiting for run run-graph"): + manager.wait_for_completion( + "run-graph", + max_wait=0, + poll_interval=1, + use_graph_state=True, + ) + + assert errors == [] + + +def test_pipeline_runs_fail_fast_hook_runs_before_lifecycle_release(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + events = [] + + class RecordingContext: + def __enter__(self): + events.append("enter") + + def __exit__(self, exc_type, exc, tb): + events.append("exit") + return False + + class Hooks(PipelineRunHooks): + def around_run(self, context): + return RecordingContext() + + def after_poll(self, poll, context): + events.append("poll") + raise PipelineRunError("fail fast") + + def on_fail_fast_before_release(self, context, error): + events.append("failfast") + + def after_run_lifecycle(self, context, *, success, error=None): + events.append("after_lifecycle") + + manager = PipelineRunManager(client=FakeClient(), hooks=Hooks()) + + with pytest.raises(PipelineRunError, match="fail fast"): + manager.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + max_attempts=1, + poll_interval=1, + ) + + assert events == ["enter", "poll", "failfast", "exit", "after_lifecycle"] + + +def test_pipeline_runs_early_exit_hook_runs_before_lifecycle_release(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + events = [] + + class RecordingContext: + def __enter__(self): + events.append("enter") + + def __exit__(self, exc_type, exc, tb): + events.append("exit") + return False + + class RunningClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + class Hooks(PipelineRunHooks): + def around_run(self, context): + return RecordingContext() + + def after_poll(self, poll, context): + events.append("poll") + + def should_exit_early(self, poll, context): + return True + + def on_early_exit_before_release(self, poll, context): + events.append("early_cleanup") + + def after_run_lifecycle(self, context, *, success, error=None): + events.append("after_lifecycle") + + manager = PipelineRunManager(client=RunningClient(), hooks=Hooks()) + + result = manager.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + poll_interval=1, + ) + + assert result["wait"]["early_exit"] is True + assert events == ["enter", "poll", "early_cleanup", "exit", "after_lifecycle"] + + +def test_pipeline_runs_retry_cancel_previous_run_before_lifecycle_release(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + events = [] + + class EventClient(FakeClient): + def pipeline_runs_cancel(self, id: str) -> None: + events.append(("cancel", id)) + return super().pipeline_runs_cancel(id) + + class RecordingContext: + def __enter__(self): + events.append(("enter", None)) + + def __exit__(self, exc_type, exc, tb): + events.append(("exit", None)) + return False + + class Hooks(PipelineRunHooks): + def around_run(self, context): + return RecordingContext() + + def after_poll(self, poll, context): + events.append(("poll", context.attempt)) + if context.attempt == 1: + raise PipelineRunError("retry me") + + def should_cancel_previous_run(self, context, error, *, next_attempt): + events.append(("should_cancel", context.run_id)) + return True + + def before_retry(self, context, error, *, next_attempt): + events.append(("before_retry", context.run_id)) + + def after_run_lifecycle(self, context, *, success, error=None): + events.append(("after_lifecycle", context.attempt)) + + client = EventClient() + manager = PipelineRunManager(client=client, hooks=Hooks()) + + manager.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + max_attempts=2, + poll_interval=1, + ) + + assert events[:7] == [ + ("enter", None), + ("poll", 1), + ("should_cancel", "run-1"), + ("cancel", "run-1"), + ("before_retry", "run-1"), + ("exit", None), + ("after_lifecycle", 1), + ] + + +def test_pipeline_runs_legacy_after_wait_only_fires_for_terminal_results(monkeypatch) -> None: + legacy_results = [] + + class RunningClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + class TimeoutHooks(PipelineRunHooks): + def after_wait(self, result): + legacy_results.append(result) + + manager = PipelineRunManager(client=RunningClient(), hooks=TimeoutHooks()) + result = manager.wait_for_completion("run-1", max_wait=0, poll_interval=1) + assert result["timed_out"] is True + assert legacy_results == [] + + class EarlyExitHooks(TimeoutHooks): + def should_exit_early(self, poll, context): + return True + + manager = PipelineRunManager(client=RunningClient(), hooks=EarlyExitHooks()) + result = manager.wait_for_completion("run-1", max_wait=1, poll_interval=1) + assert result["early_exit"] is True + assert legacy_results == [] + + manager = PipelineRunManager(client=FakeClient(), hooks=TimeoutHooks()) + result = manager.wait_for_completion("run-1", max_wait=1, poll_interval=1) + assert result["status"] == "SUCCEEDED" + assert len(legacy_results) == 1 + + +def test_pipeline_runs_run_pipeline_lifecycle_and_retry_hooks(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + events = [] + + class Hooks(PipelineRunHooks): + def around_run(self, context): + events.append(("around", context.attempt)) + return nullcontext() + + def before_run_lifecycle(self, context): + events.append(("before_lifecycle", context.attempt)) + + def after_poll(self, poll, context): + events.append(("poll", context.attempt, poll.status)) + if context.attempt == 1: + raise PipelineRunError("fail first wait") + + def should_cancel_previous_run(self, context, error, *, next_attempt): + events.append(("should_cancel", context.run_id, next_attempt)) + return True + + def before_retry(self, context, error, *, next_attempt): + events.append(("before_retry", context.run_id, next_attempt, str(error))) + + def after_retry_submit(self, context): + events.append(("after_retry_submit", context.run_id, context.attempt)) + + def after_run_lifecycle(self, context, *, success, error=None): + events.append(("after_lifecycle", context.attempt, success, str(error) if error else None)) + + client = FakeClient() + manager = PipelineRunManager(client=client, hooks=Hooks()) + + result = manager.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + max_attempts=2, + poll_interval=1, + ) + + assert result["response"]["id"] == "run-1" + assert result["wait"]["status"] == "SUCCEEDED" + assert client.cancelled == ["run-1"] + assert events == [ + ("before_lifecycle", 1), + ("around", 1), + ("poll", 1, "SUCCEEDED"), + ("should_cancel", "run-1", 2), + ("before_retry", "run-1", 2, "fail first wait"), + ("after_lifecycle", 1, False, "fail first wait"), + ("before_lifecycle", 2), + ("around", 2), + ("after_retry_submit", "run-1", 2), + ("poll", 2, "SUCCEEDED"), + ("after_lifecycle", 2, True, None), + ] + + +def test_pipeline_runs_path_retry_rebuilds_submit_body_each_attempt(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + reads = [] + events = [] + + class Hooks(PipelineRunHooks): + def read_pipeline_yaml(self, pipeline_path): + reads.append(len(reads) + 1) + return { + "name": f"Retry Source {len(reads)}", + "inputs": [{"name": "required", "type": "String"}], + "implementation": {"graph": {"tasks": {}}}, + } + + def after_poll(self, poll, context): + events.append(("poll", context.attempt, context.pipeline_spec["name"])) + if context.attempt == 1: + raise PipelineRunError("retry after first build") + + client = FakeClient() + manager = PipelineRunManager(client=client, hooks=Hooks()) + + result = manager.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + max_attempts=2, + poll_interval=1, + ) + + assert result["wait"]["status"] == "SUCCEEDED" + assert reads == [1, 2] + assert [body["root_task"]["componentRef"]["spec"]["name"] for body in client.created] == [ + "Retry Source 1", + "Retry Source 2", + ] + assert events == [ + ("poll", 1, "Retry Source 1"), + ("poll", 2, "Retry Source 2"), + ] + + + +def test_pipeline_runs_submit_failure_recovery_adopts_existing_run(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda _seconds: None) + pipeline_path = tmp_path / "pipeline.yaml" + + class DynamicTimeHooks(PipelineRunnerHooks): + def __init__(self) -> None: + super().__init__() + self.prepare_run_arguments_calls = 0 + self.submit_errors: list[str] = [] + + def on_submit_error(self, error, *, context): + del context + self.submit_errors.append(str(error)) + + def read_pipeline_yaml(self, pipeline_path): + return { + "name": "template-source", + "inputs": [{"name": "exec_time", "type": "String"}], + "metadata": {"annotations": {"run-name-template": "run-${arguments.exec_time}"}}, + "implementation": {"graph": {"tasks": {}}}, + } + + def prepare_run_arguments(self, pipeline_spec, run_args): + del pipeline_spec + self.prepare_run_arguments_calls += 1 + updated = dict(run_args or {}) + updated["exec_time"] = f"time-{self.prepare_run_arguments_calls}" + return updated + + class RecoveringClient(FakeClient): + def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: + self.created.append(copy.deepcopy(body)) + raise TimeoutError("submit timed out") + + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + return { + "pipeline_runs": [ + {"id": "run-created", "root_execution_id": "exec-created", "pipeline_name": "run-time-1"} + ], + "next_page_token": None, + } + + hooks = DynamicTimeHooks() + client = RecoveringClient() + manager = PipelineRunner(client=client, hooks=hooks) + + result = manager.run_pipeline(pipeline_path, hydrate=False) + + assert result["response"]["id"] == "run-created" + assert result["context"].run_id == "run-created" + assert result["context"].root_execution_id == "exec-created" + assert result["context"].metadata["recovered_after_submit_error"] is True + assert hooks.prepare_run_arguments_calls == 1 + assert hooks.submit_errors == [] + assert len(client.created) == 1 + submission_id = client.created[0]["annotations"]["tangle-cli/submission-id"] + assert submission_id + assert len(client.list_calls) == 1 + assert "tangle-cli/submission-id" in client.list_calls[0]["filter_query"] + assert submission_id in client.list_calls[0]["filter_query"] + + +def test_pipeline_runs_submit_failure_reuses_frozen_body_when_recovery_finds_no_run( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda _seconds: None) + pipeline_path = tmp_path / "pipeline.yaml" + + class DynamicTimeHooks(PipelineRunnerHooks): + def __init__(self) -> None: + super().__init__() + self.prepare_run_arguments_calls = 0 + self.submit_errors: list[str] = [] + + def on_submit_error(self, error, *, context): + del context + self.submit_errors.append(str(error)) + + def read_pipeline_yaml(self, pipeline_path): + return { + "name": "template-source", + "inputs": [{"name": "exec_time", "type": "String"}], + "metadata": {"annotations": {"run-name-template": "run-${arguments.exec_time}"}}, + "implementation": {"graph": {"tasks": {}}}, + } + + def prepare_run_arguments(self, pipeline_spec, run_args): + del pipeline_spec + self.prepare_run_arguments_calls += 1 + updated = dict(run_args or {}) + updated["exec_time"] = f"time-{self.prepare_run_arguments_calls}" + return updated + + class RetryClient(FakeClient): + def __init__(self) -> None: + super().__init__() + self.create_calls = 0 + + def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: + self.create_calls += 1 + self.created.append(copy.deepcopy(body)) + if self.create_calls == 1: + raise TimeoutError("submit timed out") + return {"id": "run-2", "root_execution_id": "exec-2"} + + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + return {"pipeline_runs": [], "next_page_token": None} + + hooks = DynamicTimeHooks() + client = RetryClient() + manager = PipelineRunner(client=client, hooks=hooks) + + result = manager.run_pipeline(pipeline_path, hydrate=False, max_attempts=2) + + assert result["response"]["id"] == "run-2" + assert hooks.prepare_run_arguments_calls == 1 + assert hooks.submit_errors == ["submit timed out"] + assert [body["root_task"]["arguments"]["exec_time"] for body in client.created] == ["time-1", "time-1"] + assert [body["root_task"]["componentRef"]["spec"]["name"] for body in client.created] == [ + "run-time-1", + "run-time-1", + ] + assert client.created[0]["annotations"]["tangle-cli/submission-id"] == client.created[1]["annotations"][ + "tangle-cli/submission-id" + ] + assert len(client.list_calls) == 4 + + + +def test_pipeline_runs_recovered_retry_runs_after_retry_submit_hook(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda _seconds: None) + pipeline_path = tmp_path / "pipeline.yaml" + events: list[tuple[str, int, str | None]] = [] + + class DynamicTimeHooks(PipelineRunnerHooks): + def __init__(self) -> None: + super().__init__() + self.prepare_run_arguments_calls = 0 + + def read_pipeline_yaml(self, pipeline_path): + return { + "name": "template-source", + "inputs": [{"name": "exec_time", "type": "String"}], + "metadata": {"annotations": {"run-name-template": "run-${arguments.exec_time}"}}, + "implementation": {"graph": {"tasks": {}}}, + } + + def prepare_run_arguments(self, pipeline_spec, run_args): + del pipeline_spec + self.prepare_run_arguments_calls += 1 + updated = dict(run_args or {}) + updated["exec_time"] = f"time-{self.prepare_run_arguments_calls}" + return updated + + def before_retry(self, context, error, *, next_attempt): + del error + events.append(("before_retry", next_attempt, context.run_id)) + + def after_retry_submit(self, context): + events.append(("after_retry_submit", context.attempt, context.run_id)) + + class RecoverOnRetryClient(FakeClient): + def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: + self.created.append(copy.deepcopy(body)) + raise TimeoutError("submit timed out") + + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + if len(self.list_calls) <= 2: + return {"pipeline_runs": [], "next_page_token": None} + return { + "pipeline_runs": [ + {"id": "run-created", "root_execution_id": "exec-created", "pipeline_name": "run-time-1"} + ], + "next_page_token": None, + } + + hooks = DynamicTimeHooks() + client = RecoverOnRetryClient() + manager = PipelineRunner(client=client, hooks=hooks) + + result = manager.run_pipeline(pipeline_path, hydrate=False, max_attempts=2) + + assert result["response"]["id"] == "run-created" + assert result["context"].attempt == 2 + assert result["context"].metadata["recovered_after_submit_error"] is True + assert hooks.prepare_run_arguments_calls == 1 + assert len(client.created) == 1 + assert events == [("before_retry", 2, None), ("after_retry_submit", 2, "run-created")] + + +def test_pipeline_runs_run_pipeline_spec_uses_in_memory_spec_lifecycle() -> None: + events = [] + spec = { + "name": "Prepared Spec", + "inputs": [{"name": "required", "type": "String"}], + "implementation": {"graph": {"tasks": {}}}, + } + + class Hooks(PipelineRunHooks): + def around_run(self, context): + events.append(("around", context.run_name, context.pipeline_path)) + return nullcontext() + + def before_submit_context(self, context): + events.append(("before_submit", context.run_name, context.pipeline_spec["name"])) + + def after_submit_context(self, context): + events.append(("after_submit", context.run_id, context.root_execution_id)) + + client = FakeClient() + manager = PipelineRunManager(client=client, hooks=Hooks()) + + result = manager.run_pipeline_spec( + spec, + run_args={"required": "value"}, + annotations={"team": "oss"}, + pipeline_path="already-prepared.yaml", + wait=False, + ) + + assert result["response"]["id"] == "run-1" + assert result["context"].run_id == "run-1" + assert client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Prepared Spec" + assert client.created[0]["annotations"]["team"] == "oss" + assert client.created[0]["annotations"]["tangle-cli/submission-id"] + assert events == [ + ("around", "Prepared Spec", "already-prepared.yaml"), + ("before_submit", "Prepared Spec", "Prepared Spec"), + ("after_submit", "run-1", "exec-1"), + ] + + +def test_pipeline_runs_run_prepared_body_retry_body_factory() -> None: + events = [] + base_body = { + "root_task": { + "componentRef": {"spec": {"name": "Body", "implementation": {"graph": {"tasks": {}}}}}, + "arguments": {"attempt": 1}, + }, + "annotations": {}, + } + + class Hooks(PipelineRunHooks): + def after_poll(self, poll, context): + events.append(("poll", context.attempt, context.submit_body["root_task"]["arguments"])) + if context.attempt == 1: + raise PipelineRunError("retry first body") + + def before_retry(self, context, error, *, next_attempt): + events.append(("before_retry", context.run_id, next_attempt, str(error))) + + def after_retry_submit(self, context): + events.append(("after_retry_submit", context.attempt)) + + def retry_body_factory(attempt, previous_context, error): + assert attempt == 2 + assert previous_context.run_id == "run-1" + assert str(error) == "retry first body" + retry_body = copy.deepcopy(base_body) + retry_body["root_task"]["arguments"] = {"attempt": attempt} + return retry_body + + client = FakeClient() + manager = PipelineRunManager(client=client, hooks=Hooks()) + + result = manager.run_prepared_body( + base_body, + wait=True, + max_attempts=2, + poll_interval=1, + retry_body_factory=retry_body_factory, + ) + + assert result["wait"]["status"] == "SUCCEEDED" + assert [body["root_task"]["arguments"] for body in client.created] == [ + {"attempt": 1}, + {"attempt": 2}, + ] + assert events == [ + ("poll", 1, {"attempt": 1}), + ("before_retry", "run-1", 2, "retry first body"), + ("after_retry_submit", 2), + ("poll", 2, {"attempt": 2}), + ] + + +def test_pipeline_runs_wait_exit_on_first_failure_exits_on_failed() -> None: + class FailedGraphClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"status_totals": {"RUNNING": 2, "FAILED": 1}} + + manager = PipelineRunManager(client=FailedGraphClient()) + + result = manager.wait_for_completion( + "run-1", + max_wait=10, + poll_interval=1, + use_graph_state=True, + exit_on_first_failure=True, + ) + + assert result["early_exit"] is True + assert result["failed_count"] == 1 + assert result["error_count"] == 0 + assert result["status_counts"] == {"RUNNING": 2, "FAILED": 1} + assert isinstance(result["elapsed_seconds"], float) + + +def test_pipeline_runs_wait_exit_on_first_failure_exits_on_system_error() -> None: + class ErrorGraphClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"status_totals": {"RUNNING": 2, "SYSTEM_ERROR": 1}} + + manager = PipelineRunManager(client=ErrorGraphClient()) + + result = manager.wait_for_completion( + "run-1", + max_wait=10, + poll_interval=1, + use_graph_state=True, + exit_on_first_failure=True, + ) + + assert result["early_exit"] is True + assert result["failed_count"] == 0 + assert result["error_count"] == 1 + assert result["status_counts"] == {"RUNNING": 2, "SYSTEM_ERROR": 1} + + +def test_pipeline_runs_wait_exit_on_first_failure_disabled_does_not_exit(monkeypatch) -> None: + class FailedGraphClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + def executions_graph_execution_state(self, id: str) -> dict[str, Any]: + return {"status_totals": {"RUNNING": 2, "FAILED": 1}} + + manager = PipelineRunManager(client=FailedGraphClient()) + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) + + result = manager.wait_for_completion( + "run-1", + max_wait=0, + poll_interval=1, + use_graph_state=True, + timeout_clock="wall", + ) + + assert result["timed_out"] is True + assert "early_exit" not in result + assert result["failed_count"] == 1 + assert sleeps == [] + + +def test_pipeline_runs_wait_timeout_still_routes_via_timeout_hook() -> None: + events = [] + + class RunningClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + class Hooks(PipelineRunHooks): + def should_exit_early(self, poll, context): + events.append("should_exit_early") + return super().should_exit_early(poll, context) + + def on_timeout(self, poll, context): + events.append("timeout") + + def on_early_exit_before_release(self, poll, context): + events.append("early_exit") + + manager = PipelineRunManager(client=RunningClient(), hooks=Hooks()) + + result = manager.wait_for_completion( + "run-1", + max_wait=0, + poll_interval=1, + exit_on_first_failure=True, + ) + + assert result["timed_out"] is True + assert "early_exit" not in result + assert events == ["should_exit_early", "timeout"] + + +def test_pipeline_runs_wait_allows_zero_poll_interval_when_opted_in() -> None: + class RunningClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"RUNNING": 1}, + } + + manager = PipelineRunManager(client=RunningClient()) + + result = manager.wait_for_completion( + "run-1", + max_wait=0, + poll_interval=0, + allow_zero_poll_interval=True, + timeout_clock="wall", + ) + + assert result["timed_out"] is True + + +def test_pipeline_run_status_uses_deterministic_precedence() -> None: + run = { + "execution_status_stats": { + "QUEUED": 3, + "PENDING": 2, + "RUNNING": 1, + } + } + assert PipelineRunManager.status_from_run(run) == "RUNNING" + + terminal_run = { + "execution_status_stats": { + "SUCCEEDED": 3, + "SKIPPED": 2, + "FAILED": 1, + } + } + assert PipelineRunManager.status_from_run(terminal_run) == "FAILED" + + +def test_pipeline_runs_wait_is_bounded_and_testable(monkeypatch): + fake_client = FakeClient() + manager = PipelineRunManager(client=fake_client) + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) + + result = manager.wait_for_completion("run-1", max_wait=1, poll_interval=0.01) + + assert result["timed_out"] is False + assert result["status"] == "SUCCEEDED" + assert sleeps == [] + + +def test_pipeline_runs_wait_rejects_unbounded_or_invalid_polling() -> None: + manager = PipelineRunManager(client=FakeClient()) + + with pytest.raises(PipelineRunError, match="--max-wait"): + manager.wait_for_completion("run-1", max_wait=-1, poll_interval=1) + with pytest.raises(PipelineRunError, match="--poll-interval"): + manager.wait_for_completion("run-1", max_wait=1, poll_interval=0) + + +def test_pipeline_runs_run_as_is_extension_seam(tmp_path: Path): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + manager = PipelineRunManager(client=FakeClient()) + + with pytest.raises(PipelineRunError, match="--run-as"): + manager.submit_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + run_as="service@example.com", + ) + + +def test_pipeline_runner_orchestrates_load_validate_submit_wait(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + client = FakeClient() + calls: list[str] = [] + + class Hooks(PipelineRunnerHooks): + def validate_pipeline_for_run(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] + calls.append(f"validate:{pipeline_spec['name']}:{kwargs['skip_validation']}") + return [] + + def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] + calls.append("before_submit") + updated = copy.deepcopy(pipeline_spec) + updated["metadata"] = {"annotations": {"run-name-template": "Run ${arguments.required}"}} + return updated + + runner = PipelineRunner(client=client, hooks=Hooks()) + + result = runner.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + annotations={"team": "oss"}, + hydrate=False, + wait=True, + use_graph_state=False, + max_wait=1, + poll_interval=0.01, + ) + + assert result["success"] is True + assert result["status"] == "SUCCEEDED" + assert result["pipeline_name"] == "Run value" + assert result["run_id"] == "run-1" + assert calls == ["validate:Demo Pipeline:False", "before_submit"] + assert client.created[0]["annotations"]["team"] == "oss" + assert client.created[0]["annotations"]["tangle-cli/submission-id"] + assert client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Run value" + + +def test_pipeline_runner_maps_non_mapping_yaml_to_run_error(tmp_path: Path) -> None: + pipeline_path = tmp_path / "bad.yaml" + pipeline_path.write_text("[]\n", encoding="utf-8") + runner = PipelineRunner(client=FakeClient()) + + with pytest.raises(PipelineRunError, match="top-level mapping"): + runner.run_pipeline(pipeline_path, hydrate=False) + + +def test_pipeline_runner_layout_is_hookable(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + client = FakeClient() + + class Hooks(PipelineRunnerHooks): + def should_apply_layout(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] + assert kwargs["force_layout"] is True + assert kwargs["layout_algorithm"] == "dot" + return True + + def apply_layout(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] + updated = copy.deepcopy(pipeline_spec) + updated["layout_hook_ran"] = True + return updated + + runner = PipelineRunner(client=client, hooks=Hooks()) + + runner.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + force_layout=True, + layout_algorithm="dot", + ) + + assert client.created[0]["root_task"]["componentRef"]["spec"]["layout_hook_ran"] is True + + +def test_pipeline_runner_path_retry_prepares_each_attempt(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + + class FlakyClient(FakeClient): + def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: + self.created.append(body) + if len(self.created) == 1: + raise PipelineRunError("transient submit failure") + return {"id": "run-2", "root_execution_id": "exec-2"} + + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.list_calls.append(kwargs) + return {"pipeline_runs": [], "next_page_token": None} + + class Hooks(PipelineRunnerHooks): + def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] + updated = copy.deepcopy(pipeline_spec) + updated["name"] = f"attempt-{len(client.created) + 1}" + return updated + + client = FlakyClient() + runner = PipelineRunner(client=client, hooks=Hooks()) + + result = runner.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + max_attempts=2, + max_wait=1, + poll_interval=0.01, + ) + + assert result["run_id"] == "run-2" + assert [body["root_task"]["componentRef"]["spec"]["name"] for body in client.created] == [ + "attempt-1", + "attempt-1", + ] + + +def test_pipeline_runner_wait_failed_status_is_not_success(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + + class FailedClient(FakeClient): + def pipeline_runs_get(self, id: str, include_execution_stats: bool | None = None) -> dict[str, Any]: + return { + "id": id, + "root_execution_id": "exec-1", + "execution_status_stats": {"FAILED": 1}, + } + + runner = PipelineRunner(client=FailedClient()) + + result = runner.run_pipeline( + pipeline_path, + run_args={"required": "value"}, + hydrate=False, + wait=True, + max_wait=1, + poll_interval=0.01, + ) + + assert result["status"] == "FAILED" + assert result["success"] is False + + +def test_pipeline_runner_cleanup_runs_after_prepare_failure(tmp_path: Path) -> None: + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + temp_effective_path = tmp_path / "hydrated.yaml" + temp_effective_path.write_text("name: hydrated\n", encoding="utf-8") + cleaned: list[tuple[Path | None, str | None]] = [] + + class Hooks(PipelineRunnerHooks): + def hydrate_pipeline_for_run(self, pipeline_path, **kwargs): # type: ignore[no-untyped-def] + return yaml.safe_load(Path(pipeline_path).read_text(encoding="utf-8")), temp_effective_path + + def validate_pipeline_for_run(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] + return ["boom"] + + def cleanup_prepared_pipeline(self, preparation, *, error=None): # type: ignore[no-untyped-def] + path = Path(preparation.effective_path) if preparation.effective_path is not None else None + cleaned.append((path, str(error) if error else None)) + if path is not None: + path.unlink(missing_ok=True) + + runner = PipelineRunner(client=FakeClient(), hooks=Hooks()) + + with pytest.raises(PipelineRunError, match="boom"): + runner.run_pipeline(pipeline_path, hydrate=True) + + assert cleaned == [(temp_effective_path, "Pipeline validation failed:\n - boom")] + assert not temp_effective_path.exists() diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py new file mode 100644 index 0000000..90bb633 --- /dev/null +++ b/tests/test_pipelines_cli.py @@ -0,0 +1,1532 @@ +import json +import subprocess +import sys +import textwrap +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import yaml + +from tangle_cli import cli +from tangle_cli.pipeline_hydrator import PipelineHydrator + + +def run_app(app, args: list[str]) -> None: + try: + app(args) + except SystemExit as exc: + if exc.code not in (0, None): + raise + + +def _write_pipeline(path: Path, data: dict) -> Path: + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return path + + +def _minimal_valid_pipeline() -> dict: + return { + "name": "Demo Pipeline", + "implementation": { + "graph": { + "tasks": { + "extract": { + "componentRef": { + "spec": { + "name": "Extract", + "outputs": [{"name": "rows", "type": "String"}], + } + } + }, + "load": { + "componentRef": { + "spec": { + "name": "Load", + "inputs": [{"name": "rows", "type": "String"}], + } + }, + "arguments": { + "rows": { + "taskOutput": {"taskId": "extract", "outputName": "rows"} + } + }, + }, + } + } + }, + } + + +def test_sdk_help_includes_pipelines(capsys): + app = cli.build_app() + + run_app(app, ["sdk", "--help"]) + + output = capsys.readouterr().out + assert "artifacts" in output + assert "components" in output + assert "pipelines" in output + assert "published-components" in output + assert "secrets" in output + + +def test_sdk_pipelines_help_lists_local_commands(capsys): + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "--help"]) + + output = capsys.readouterr().out + assert "validate" in output + assert "hydrate" in output + assert "diagram" in output + assert "layout" in output + assert "pipeline-runs" not in output + assert "compile" not in output + + +def test_pipeline_hydrator_generic_local_resolver_priority(tmp_path: Path): + local_yaml = tmp_path / "local.yaml" + local_yaml.write_text(yaml.safe_dump({"name": "Local"}), encoding="utf-8") + ignored_yaml = tmp_path / "ignored.yaml" + ignored_yaml.write_text(yaml.safe_dump({"name": "Ignored"}), encoding="utf-8") + hydrator = PipelineHydrator(client=MagicMock()) + + result = hydrator._try_resolve_entry( + {"local": "./local.yaml", "local_from_docker": {"source": "./ignored.yaml"}}, + "demo.task", + tmp_path, + ) + + assert result is not None + assert result[1]["name"] == "Local" + + +def test_pipeline_hydrator_generic_preview_skips_local_materialization(tmp_path: Path): + source = tmp_path / "component.py" + source.write_text( + 'def component():\n """Demo.\n\n Metadata:\n version: 1.0.0\n """\n', + encoding="utf-8", + ) + hydrator = PipelineHydrator(client=MagicMock()) + hydrator._resolve_registered_component = MagicMock(return_value=("local", {"name": "Local"})) # type: ignore[method-assign] + + result = hydrator._try_resolve_entry( + {"local_from_python": {"file": "./component.py", "output_folder": "./generated"}}, + "demo.task", + tmp_path, + ) + + assert result == ("local", {"name": "Local"}) + hydrator._resolve_registered_component.assert_called_once() + + hydrator._resolve_registered_component.reset_mock() + hydrator._resolve_primary = MagicMock( # type: ignore[method-assign] + return_value=( + "primary", + {"name": "Published", "metadata": {"annotations": {"version": "2.0.0"}}}, + ) + ) + result = hydrator._try_resolve_entry( + {"local_from_python": {"file": "./component.py", "output_folder": "./generated"}}, + "demo.task", + tmp_path, + ) + + assert result == ( + "primary", + {"name": "Published", "metadata": {"annotations": {"version": "2.0.0"}}}, + ) + hydrator._resolve_registered_component.assert_not_called() + + +def test_pipelines_validate_succeeds_for_minimal_valid_yaml(tmp_path: Path, capsys): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml", _minimal_valid_pipeline()) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "validate", str(pipeline_path)]) + + assert "Valid pipeline" in capsys.readouterr().out + + +def test_pipelines_validate_fails_for_invalid_yaml(tmp_path: Path): + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Broken Pipeline", + "implementation": { + "graph": { + "tasks": { + "load": { + "componentRef": {"name": "Load"}, + "arguments": { + "rows": { + "taskOutput": { + "taskId": "missing", + "outputName": "rows", + } + } + }, + } + } + } + }, + }, + ) + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "pipelines", "validate", str(pipeline_path)]) + + assert exc_info.value.code != 0 + assert "unknown task 'missing'" in str(exc_info.value) + + +def test_pipelines_validate_rejects_non_string_task_ids(tmp_path: Path): + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Broken Pipeline", + "implementation": { + "graph": { + "tasks": { + 1: {"componentRef": {"name": "Leaf"}}, + }, + }, + }, + }, + ) + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "pipelines", "validate", str(pipeline_path)]) + + assert exc_info.value.code != 0 + assert "task ids must be strings" in str(exc_info.value) + + +def test_pipelines_diagram_outputs_small_dependency_graph(tmp_path: Path, capsys): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml", _minimal_valid_pipeline()) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "diagram", str(pipeline_path)]) + + output = capsys.readouterr().out + assert "```mermaid" in output + assert "flowchart LR" in output + assert "extract --> load" in output + assert "Extract" in output + assert "Load" in output + + +def test_pipelines_hydrate_renders_template_and_resolves_local_file_refs( + tmp_path: Path, + capsys, +): + components_dir = tmp_path / "components" + components_dir.mkdir() + _write_pipeline( + components_dir / "echo.yaml", + { + "name": "Echo Component", + "inputs": [{"name": "message", "type": "String"}], + "outputs": [{"name": "result", "type": "String"}], + "implementation": {"container": {"image": "python:3.12"}}, + }, + ) + (tmp_path / "pipeline.yaml.j2").write_text( + "name: {{ pipeline_name }}\n" + "implementation:\n" + " graph:\n" + " tasks:\n" + " echo:\n" + " componentRef:\n" + " url: file://{{ component_file }}\n", + encoding="utf-8", + ) + config_path = _write_pipeline( + tmp_path / "pipeline.config.yaml", + { + "template_file": "pipeline.yaml.j2", + "pipeline_name": "Config Name", + "component_file": "components/echo.yaml", + }, + ) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipelines", + "hydrate", + str(config_path), + "--var", + "pipeline_name=Hydrated Pipeline", + ], + ) + + hydrated = yaml.safe_load(capsys.readouterr().out) + assert hydrated["name"] == "Hydrated Pipeline" + task = hydrated["implementation"]["graph"]["tasks"]["echo"] + assert set(task["componentRef"]) == {"name", "digest", "spec"} + assert task["componentRef"]["name"] == "Echo Component" + assert task["componentRef"]["spec"]["implementation"]["container"]["image"] == "python:3.12" + + +def test_pipelines_hydrate_log_type_none_suppresses_progress(tmp_path: Path, capsys): + component_path = _write_pipeline( + tmp_path / "component.yaml", + { + "name": "Local Component", + "implementation": {"container": {"image": "python:3.12"}}, + }, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "local": { + "componentRef": {"url": f"file://{component_path.name}"} + } + } + } + }, + }, + ) + app = cli.build_app() + + run_app( + app, + ["sdk", "pipelines", "hydrate", str(pipeline_path), "--log-type", "none"], + ) + + captured = capsys.readouterr() + hydrated = yaml.safe_load(captured.out) + assert hydrated["implementation"]["graph"]["tasks"]["local"]["componentRef"]["spec"]["name"] == "Local Component" + assert captured.err == "" + + +def test_pipelines_hydrate_writes_output_when_requested(tmp_path: Path, capsys): + component_path = _write_pipeline( + tmp_path / "component.yaml", + { + "name": "Local Component", + "implementation": {"container": {"image": "python:3.12"}}, + }, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "local": { + "componentRef": {"url": f"file://{component_path.name}"} + } + } + } + }, + }, + ) + output_path = tmp_path / "hydrated.yaml" + app = cli.build_app() + + run_app( + app, + ["sdk", "pipelines", "hydrate", str(pipeline_path), "--output", str(output_path)], + ) + + assert "1 component(s) resolved" in capsys.readouterr().out + hydrated = yaml.safe_load(output_path.read_text(encoding="utf-8")) + local_ref = hydrated["implementation"]["graph"]["tasks"]["local"]["componentRef"] + assert local_ref["spec"]["name"] == "Local Component" + + +def test_pipelines_hydrate_local_file_refs_do_not_import_native_api(tmp_path: Path): + component_path = _write_pipeline( + tmp_path / "component.yaml", + { + "name": "Local Only Component", + "implementation": {"container": {"image": "python:3.12"}}, + }, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "local": { + "componentRef": {"url": f"file://{component_path.name}"} + } + } + } + }, + }, + ) + script = textwrap.dedent( + f""" + import builtins + import sys + from pathlib import Path + + for name in list(sys.modules): + if name == "tangle_api" or name.startswith("tangle_api."): + del sys.modules[name] + + original_import = builtins.__import__ + + def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "tangle_api" or name.startswith("tangle_api."): + raise AssertionError(f"unexpected native API import: {{name}}") + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = guarded_import + + from tangle_cli.pipelines import hydrate_pipeline_file + + result = hydrate_pipeline_file(Path({str(pipeline_path)!r})) + assert "Local Only Component" in result.content + assert "tangle_api" not in sys.modules + """ + ) + + subprocess.run([sys.executable, "-c", script], check=True, text=True) + + +def test_pipelines_hydrate_nested_file_refs_use_loaded_component_source_dir( + tmp_path: Path, + capsys, +): + subgraphs_dir = tmp_path / "subgraphs" + components_dir = tmp_path / "components" + subgraphs_dir.mkdir() + components_dir.mkdir() + _write_pipeline( + components_dir / "grandchild.yaml", + { + "name": "Grandchild Component", + "implementation": {"container": {"image": "python:3.12"}}, + }, + ) + _write_pipeline( + subgraphs_dir / "child.yaml", + { + "name": "Child Subgraph", + "implementation": { + "graph": { + "tasks": { + "grandchild": { + "componentRef": { + "url": "file://./../components/grandchild.yaml" + } + } + } + } + }, + }, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "child": {"componentRef": {"url": "file://./subgraphs/child.yaml"}} + } + } + }, + }, + ) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + child_spec = hydrated["implementation"]["graph"]["tasks"]["child"]["componentRef"]["spec"] + grandchild_ref = child_spec["implementation"]["graph"]["tasks"]["grandchild"]["componentRef"] + assert grandchild_ref["name"] == "Grandchild Component" + assert grandchild_ref["spec"]["implementation"]["container"]["image"] == "python:3.12" + assert "_source_dir" not in child_spec + assert "_source_dir" not in grandchild_ref["spec"] + + +def test_pipelines_hydrate_cache_separates_same_relative_ref_by_source_dir( + tmp_path: Path, + capsys, +): + left_dir = tmp_path / "left" + right_dir = tmp_path / "right" + left_dir.mkdir() + right_dir.mkdir() + _write_pipeline( + left_dir / "leaf.yaml", + { + "name": "Left Leaf", + "implementation": {"container": {"image": "left:latest"}}, + }, + ) + _write_pipeline( + right_dir / "leaf.yaml", + { + "name": "Right Leaf", + "implementation": {"container": {"image": "right:latest"}}, + }, + ) + _write_pipeline( + left_dir / "subgraph.yaml", + { + "name": "Left Subgraph", + "implementation": { + "graph": { + "tasks": {"leaf": {"componentRef": {"url": "file://leaf.yaml"}}} + } + }, + }, + ) + _write_pipeline( + right_dir / "subgraph.yaml", + { + "name": "Right Subgraph", + "implementation": { + "graph": { + "tasks": {"leaf": {"componentRef": {"url": "file://leaf.yaml"}}} + } + }, + }, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "left": {"componentRef": {"url": "file://left/subgraph.yaml"}}, + "right": {"componentRef": {"url": "file://right/subgraph.yaml"}}, + } + } + }, + }, + ) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + tasks = hydrated["implementation"]["graph"]["tasks"] + left_leaf = tasks["left"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["leaf"]["componentRef"] + right_leaf = tasks["right"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["leaf"]["componentRef"] + assert left_leaf["name"] == "Left Leaf" + assert left_leaf["spec"]["implementation"]["container"]["image"] == "left:latest" + assert right_leaf["name"] == "Right Leaf" + assert right_leaf["spec"]["implementation"]["container"]["image"] == "right:latest" + + +def test_pipelines_hydrate_cache_separates_relative_resolve_refs_by_source_dir( + tmp_path: Path, + capsys, +): + left_dir = tmp_path / "left" + right_dir = tmp_path / "right" + left_dir.mkdir() + right_dir.mkdir() + for side, image in (("left", "left:latest"), ("right", "right:latest")): + side_dir = tmp_path / side + _write_pipeline( + side_dir / "leaf.yaml", + { + "name": f"{side.title()} Leaf", + "implementation": {"container": {"image": image}}, + }, + ) + _write_pipeline( + side_dir / "components.resolve.yaml", + {"leaf": {"url": "file://leaf.yaml"}}, + ) + _write_pipeline( + side_dir / "subgraph.yaml", + { + "name": f"{side.title()} Subgraph", + "implementation": { + "graph": { + "tasks": { + "leaf": { + "componentRef": { + "url": "resolve://./components.resolve.yaml#leaf" + } + } + } + } + }, + }, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "left": {"componentRef": {"url": "file://left/subgraph.yaml"}}, + "right": {"componentRef": {"url": "file://right/subgraph.yaml"}}, + } + } + }, + }, + ) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + tasks = hydrated["implementation"]["graph"]["tasks"] + left_leaf = tasks["left"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["leaf"]["componentRef"] + right_leaf = tasks["right"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["leaf"]["componentRef"] + assert left_leaf["name"] == "Left Leaf" + assert left_leaf["spec"]["implementation"]["container"]["image"] == "left:latest" + assert right_leaf["name"] == "Right Leaf" + assert right_leaf["spec"]["implementation"]["container"]["image"] == "right:latest" + + +def test_pipelines_hydrate_resolve_url_fragment_uses_config_relative_local_refs( + tmp_path: Path, + capsys, +): + root = tmp_path / "project" + pipeline_dir = root / "pipelines" + component_dir = root / "components" + pipeline_dir.mkdir(parents=True) + component_dir.mkdir() + _write_pipeline( + component_dir / "truncate.yaml", + { + "name": "Truncate If Time", + "metadata": {"annotations": {"version": "1.0"}}, + "implementation": {"container": {"image": "python:3.12"}}, + }, + ) + _write_pipeline( + root / "components.resolve.yaml", + { + "_defaults": {"publisher": "unused@example.com"}, + "truncate-if-time": {"local": "components/truncate.yaml"}, + }, + ) + pipeline_path = _write_pipeline( + pipeline_dir / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "truncate": { + "componentRef": { + "url": "resolve://../components.resolve.yaml#truncate-if-time" + } + } + } + } + }, + }, + ) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + ref = hydrated["implementation"]["graph"]["tasks"]["truncate"]["componentRef"] + assert ref["name"] == "Truncate If Time" + assert ref["spec"]["metadata"]["annotations"]["version"] == "1.0" + + +def test_pipelines_hydrate_http_url_refs(monkeypatch, tmp_path: Path, capsys): + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self): + return ( + b"name: Remote Component\n" + b"implementation:\n" + b" container:\n" + b" image: python:3.12\n" + ) + + import urllib.request + + monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout: FakeResponse()) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "remote": { + "componentRef": {"url": "https://example.test/component.yaml"} + } + } + } + }, + }, + ) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + ref = hydrated["implementation"]["graph"]["tasks"]["remote"]["componentRef"] + assert ref["name"] == "Remote Component" + assert ref["spec"]["implementation"]["container"]["image"] == "python:3.12" + + +def _write_local_from_python_pipeline( + project_dir: Path, + python_file: str, + *, + resolve_root: str | None = None, +) -> Path: + gen_config = { + "file": python_file, + "output_folder": "./generated", + } + if resolve_root is not None: + gen_config["resolve_root"] = resolve_root + _write_pipeline( + project_dir / "components.resolve.yaml", + {"generated": {"local_from_python": gen_config}}, + ) + return _write_pipeline( + project_dir / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "generated": { + "componentRef": {"url": "resolve://./components.resolve.yaml#generated"} + } + } + } + }, + }, + ) + + +def test_pipelines_hydrate_local_from_python_trusts_project_paths( + monkeypatch, + tmp_path: Path, + capsys, +): + from tangle_cli import pipeline_hydrator as hydrator_module + + project_dir = tmp_path / "project" + project_dir.mkdir() + python_file = project_dir / "component.py" + python_file.write_text("# trusted project component\n", encoding="utf-8") + pipeline_path = _write_local_from_python_pipeline(project_dir, "./component.py") + regenerated: list[Path] = [] + + def fake_regenerate_yaml(**kwargs): + regenerated.append(kwargs["python_file"]) + kwargs["output_path"].write_text( + "name: Generated Component\nimplementation:\n container:\n image: busybox\n", + encoding="utf-8", + ) + return True + + monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + ref = hydrated["implementation"]["graph"]["tasks"]["generated"]["componentRef"] + assert ref["name"] == "Generated Component" + assert regenerated == [python_file.resolve()] + + +def test_pipelines_hydrate_local_from_python_refuses_untrusted_absolute_path( + monkeypatch, + tmp_path: Path, +): + from tangle_cli import pipeline_hydrator as hydrator_module + from tangle_cli.pipelines import PipelineValidationError, hydrate_pipeline_file + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "evil.py" + outside_python.write_text("raise RuntimeError('must not execute')\n", encoding="utf-8") + pipeline_path = _write_local_from_python_pipeline(project_dir, str(outside_python)) + regenerated: list[Path] = [] + + def fake_regenerate_yaml(**kwargs): + regenerated.append(kwargs["python_file"]) + raise AssertionError("untrusted local_from_python must be blocked before generation") + + monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + + with pytest.raises(PipelineValidationError, match="Refusing to execute untrusted local_from_python source"): + hydrate_pipeline_file(pipeline_path) + assert regenerated == [] + + +def test_pipelines_hydrate_local_from_python_ignores_untrusted_resolve_root( + monkeypatch, + tmp_path: Path, +): + from tangle_cli import pipeline_hydrator as hydrator_module + from tangle_cli.pipelines import PipelineValidationError, hydrate_pipeline_file + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "evil.py" + outside_python.write_text("raise RuntimeError('must not execute')\n", encoding="utf-8") + pipeline_path = _write_local_from_python_pipeline( + project_dir, + str(outside_python), + resolve_root=str(outside_dir), + ) + + def fake_regenerate_yaml(**kwargs): + raise AssertionError("untrusted resolve_root must not authorize execution") + + monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + + with pytest.raises(PipelineValidationError, match="Refusing to execute untrusted local_from_python source"): + hydrate_pipeline_file(pipeline_path) + + +def test_pipelines_hydrate_local_from_python_blocks_symlink_escape( + monkeypatch, + tmp_path: Path, +): + from tangle_cli import pipeline_hydrator as hydrator_module + from tangle_cli.pipelines import PipelineValidationError, hydrate_pipeline_file + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "evil.py" + outside_python.write_text("raise RuntimeError('must not execute')\n", encoding="utf-8") + symlink = project_dir / "linked.py" + symlink.symlink_to(outside_python) + pipeline_path = _write_local_from_python_pipeline(project_dir, "./linked.py") + + def fake_regenerate_yaml(**kwargs): + raise AssertionError("symlink escape must be blocked before generation") + + monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + + with pytest.raises(PipelineValidationError, match="Refusing to execute untrusted local_from_python source"): + hydrate_pipeline_file(pipeline_path) + + +def test_trusted_python_source_globs_are_path_segment_aware(tmp_path: Path): + from tangle_cli.hydration_trust import is_trusted_python_source + + project_dir = tmp_path / "project" + components_dir = project_dir / "components" + nested_dir = components_dir / "nested" + nested_dir.mkdir(parents=True) + direct_source = components_dir / "component.py" + nested_source = nested_dir / "component.py" + direct_source.write_text("# trusted\n", encoding="utf-8") + nested_source.write_text("# not matched by single-segment glob\n", encoding="utf-8") + + single_segment_pattern = str(components_dir / "*.py") + recursive_pattern = str(components_dir / "**" / "*.py") + + assert is_trusted_python_source(direct_source, trusted_sources=[single_segment_pattern]) is True + assert is_trusted_python_source(nested_source, trusted_sources=[single_segment_pattern]) is False + assert is_trusted_python_source(direct_source, trusted_sources=[recursive_pattern]) is True + assert is_trusted_python_source(nested_source, trusted_sources=[recursive_pattern]) is True + + +def test_pipelines_hydrate_trusted_hydration_allows_untrusted_python_source( + monkeypatch, + tmp_path: Path, + capsys, +): + from tangle_cli import pipeline_hydrator as hydrator_module + + project_dir = tmp_path / "project" + project_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_python = outside_dir / "component.py" + outside_python.write_text("# trusted by explicit override\n", encoding="utf-8") + pipeline_path = _write_local_from_python_pipeline(project_dir, str(outside_python)) + regenerated: list[Path] = [] + + def fake_regenerate_yaml(**kwargs): + regenerated.append(kwargs["python_file"]) + kwargs["output_path"].write_text( + "name: Explicitly Trusted Component\nimplementation:\n container:\n image: busybox\n", + encoding="utf-8", + ) + return True + + monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path), "--trusted-hydration"]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + ref = hydrated["implementation"]["graph"]["tasks"]["generated"]["componentRef"] + assert ref["name"] == "Explicitly Trusted Component" + assert regenerated == [outside_python.resolve()] + + +def test_pipelines_hydrate_name_refs_use_api_without_env_credentials_for_config_base_url( + monkeypatch, + tmp_path: Path, + capsys, +): + from tangle_cli import client as client_module + + created_clients = [] + + class FakeClient: + def __init__(self, **kwargs): + created_clients.append(kwargs) + + def find_existing_components(self, components, **kwargs): + assert components == ["Remote Name", "[Official] Remote Name"] + return [SimpleNamespace(digest="sha256:remote", version="2.0")] + + def get_component_spec(self, digest): + assert digest == "sha256:remote" + return SimpleNamespace( + data={ + "name": "Remote Name", + "metadata": {"annotations": {"version": "2.0"}}, + "implementation": {"container": {"image": "python:3.12"}}, + } + ) + + monkeypatch.setenv("TANGLE_API_TOKEN", "ambient-token") + monkeypatch.setattr(client_module, "TangleApiClient", FakeClient) + config_path = _write_pipeline( + tmp_path / "hydrate-config.yaml", + {"base_url": "https://api.test"}, + ) + pipeline_path = _write_pipeline( + tmp_path / "pipeline.yaml", + { + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "remote": {"componentRef": {"name": "Remote Name"}} + } + } + }, + }, + ) + app = cli.build_app() + + run_app(app, ["sdk", "pipelines", "hydrate", str(pipeline_path), "--config", str(config_path)]) + + hydrated = yaml.safe_load(capsys.readouterr().out) + ref = hydrated["implementation"]["graph"]["tasks"]["remote"]["componentRef"] + assert ref["digest"] == "sha256:remote" + assert ref["spec"]["name"] == "Remote Name" + assert created_clients[0]["base_url"] == "https://api.test" + assert created_clients[0]["token"] is None + assert created_clients[0]["include_env_credentials"] is False + + +def test_pipeline_hydrator_resolve_config_name_uses_filters(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator + + specs = { + "sha256:old": { + "name": "Thing", + "metadata": {"annotations": {"version": "1.0", "team": "x"}}, + "implementation": {"container": {"image": "old"}}, + }, + "sha256:wrong-team": { + "name": "Thing", + "metadata": {"annotations": {"version": "3.0", "team": "y"}}, + "implementation": {"container": {"image": "wrong-team"}}, + }, + "sha256:match-low": { + "name": "Thing", + "metadata": {"annotations": {"version": "2.1", "team": "x"}}, + "implementation": {"container": {"image": "match-low"}}, + }, + "sha256:match-high": { + "name": "Thing", + "metadata": {"annotations": {"version": "2.4", "team": "x"}}, + "implementation": {"container": {"image": "match-high"}}, + }, + } + calls = [] + + class FakeClient: + def find_existing_components(self, components, **kwargs): + calls.append({"components": components, **kwargs}) + return [ + SimpleNamespace(digest="sha256:old", version="1.0"), + SimpleNamespace(digest="sha256:wrong-team", version="3.0"), + SimpleNamespace(digest="sha256:match-low", version="2.1"), + SimpleNamespace(digest="sha256:match-high", version="2.4"), + ] + + def get_component_spec(self, digest): + return SimpleNamespace(data=specs[digest]) + + hydrator = PipelineHydrator(client=FakeClient()) + + digest, spec = hydrator._resolve_from_config( + { + "name": "Thing", + "publisher": "alice@example.com", + "version": ">=2", + "annotations": {"team": "x"}, + }, + "Pipeline.task", + tmp_path, + ) + + assert digest == "sha256:match-high" + assert spec["implementation"]["container"]["image"] == "match-high" + assert calls == [ + { + "components": ["Thing", "[Official] Thing"], + "verbose": False, + "published_by": "alice@example.com", + } + ] + + +def test_pipeline_hydrator_unsupported_resolver_lists_available_resolvers(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator, UnsupportedHydrationFeatureError + + hydrator = PipelineHydrator() + + with pytest.raises(UnsupportedHydrationFeatureError) as exc_info: + hydrator._resolve_from_config( + {"local_from_docker": {"source": "component.yaml"}}, + "Pipeline.task", + tmp_path, + ) + + message = str(exc_info.value) + assert "local_from_docker" in message + assert "Available resolvers" in message + assert "file" in message + assert "local_from_python" in message + + +def test_pipeline_hydrator_resolver_registry_can_add_downstream_kind(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator + + calls = [] + + def fake_docker_resolver(hydrator, value, path, base_dir): + calls.append({"value": value, "path": path, "base_dir": base_dir}) + return ( + "sha256:docker", + {"name": "Docker Component", "implementation": {"container": {"image": "x"}}}, + ) + + hydrator = PipelineHydrator( + component_resolvers={"local_from_docker": fake_docker_resolver} + ) + + result = hydrator._resolve_from_config( + {"local_from_docker": {"source": "component.yaml"}}, + "Pipeline.task", + tmp_path, + ) + + assert result == ( + "sha256:docker", + {"name": "Docker Component", "implementation": {"container": {"image": "x"}}}, + ) + assert calls == [ + { + "value": {"source": "component.yaml"}, + "path": "Pipeline.task", + "base_dir": tmp_path, + } + ] + + +def test_pipeline_hydrator_resolver_registry_passes_structured_context(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator, ResolverContext + + calls: list[ResolverContext] = [] + + def fake_resolver(hydrator, value, path, base_dir, context): + calls.append(context) + return ( + "sha256:custom", + {"name": "Custom Component", "implementation": {"container": {"image": "x"}}}, + ) + + hydrator = PipelineHydrator( + component_resolvers={"custom": fake_resolver}, + trusted_python_sources=[str(tmp_path)], + allow_all_hydration=True, + error_policy="raise", + ) + + result = hydrator._resolve_from_config( + {"custom": {"source": "component.yaml", "output_folder": "generated"}}, + "Pipeline.task", + tmp_path, + ) + + assert result == ( + "sha256:custom", + {"name": "Custom Component", "implementation": {"container": {"image": "x"}}}, + ) + assert calls + context = calls[0] + assert context.kind == "custom" + assert context.path == "Pipeline.task" + assert context.base_dir == tmp_path + assert context.source_path == tmp_path / "component.yaml" + assert context.output_folder == tmp_path / "generated" + assert context.base_dirs[0] == tmp_path + assert context.trusted_python_sources == (str(tmp_path),) + assert context.allow_all_hydration is True + assert context.error_policy == "raise" + + +def test_pipeline_hydrator_resolver_registry_keeps_legacy_signature(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator + + calls = [] + + def legacy_resolver(hydrator, value, path, base_dir): + calls.append({"value": value, "path": path, "base_dir": base_dir}) + return ( + "sha256:legacy", + {"name": "Legacy Component", "implementation": {"container": {"image": "x"}}}, + ) + + hydrator = PipelineHydrator(component_resolvers={"legacy": legacy_resolver}) + + result = hydrator._resolve_from_config( + {"legacy": "component.yaml"}, + "Pipeline.task", + tmp_path, + ) + + assert result == ( + "sha256:legacy", + {"name": "Legacy Component", "implementation": {"container": {"image": "x"}}}, + ) + assert calls == [{"value": "component.yaml", "path": "Pipeline.task", "base_dir": tmp_path}] + + +def test_pipeline_hydrator_uri_hooks_cover_top_level_and_resolve_config(): + from tangle_cli.pipeline_hydrator import PipelineHydrator, ResolverContext + + output: dict[str, str] = {} + contexts: list[ResolverContext] = [] + sources = { + "mem://pipeline": yaml.safe_dump({ + "name": "Pipeline", + "implementation": { + "graph": { + "tasks": { + "task": {"componentRef": {"url": "resolve://mem://resolve#thing"}} + } + } + }, + }), + "mem://resolve": yaml.safe_dump({"thing": {"url": "mem://component"}}), + "mem://component": yaml.safe_dump({ + "name": "Memory Component", + "implementation": {"container": {"image": "python:3.12"}}, + }), + } + + def reader(hydrator, uri, context): + contexts.append(context) + return sources[uri] + + def writer(hydrator, uri, content, context): + contexts.append(context) + output[uri] = content + + hydrator = PipelineHydrator(uri_readers={"mem": reader}, uri_writers={"mem": writer}) + + result = hydrator.hydrate_file("mem://pipeline", "mem://hydrated") + + assert "mem://hydrated" in output + hydrated = yaml.safe_load(output["mem://hydrated"]) + ref = hydrated["implementation"]["graph"]["tasks"]["task"]["componentRef"] + assert ref["digest"] == result.data["implementation"]["graph"]["tasks"]["task"]["componentRef"]["digest"] + assert ref["spec"]["name"] == "Memory Component" + assert {context.kind for context in contexts} == {"mem"} + assert {context.path for context in contexts} >= {"pipeline", "Pipeline.task", "output"} + + +def test_pipeline_hydrator_normalizes_model_specs_from_clients_and_resolvers(): + from tangle_cli.pipeline_hydrator import PipelineHydrator + + class ModelSpec: + def __init__(self, data): + self.data = data + + def to_mutable_spec_dict(self): + return self.data + + source_spec = { + "name": "Model Component", + "metadata": {"annotations": {"version": "1.0"}}, + } + + class FakeClient: + def get_component_spec(self, digest): + assert digest == "sha256:model" + return ModelSpec(source_spec) + + hydrator = PipelineHydrator(client=FakeClient(), upgrade_deprecated=False) + + digest, fetched = hydrator.fetch_component("sha256:model") + fetched["metadata"]["annotations"]["version"] = "mutated" + + assert digest == "sha256:model" + assert source_spec["metadata"]["annotations"]["version"] == "1.0" + + hydrator.register_component_resolver( + "custom", + lambda *_args: ("sha256:custom", SimpleNamespace(data=source_spec)), + ) + resolved_digest, resolved_spec = hydrator._resolve_registered_component( + "custom", + "unused", + "Pipeline.task", + None, + ) + resolved_spec["metadata"]["annotations"]["version"] = "resolver-mutated" + + assert resolved_digest == "sha256:custom" + assert source_spec["metadata"]["annotations"]["version"] == "1.0" + + +def test_pipeline_hydrator_uri_reader_soft_failures_respect_error_policy(): + from tangle_cli.logger import CaptureLogger + from tangle_cli.pipeline_hydrator import HydrationError, PipelineHydrator + + sources = { + "mem://missing": FileNotFoundError("missing"), + "mem://invalid": "name: [", + "mem://empty": "", + "mem://list": "- not\n- a mapping\n", + "mem://template": yaml.safe_dump({"template_file": "component.yaml.j2"}), + } + + def reader(_hydrator, uri, _context): + value = sources[uri] + if isinstance(value, Exception): + raise value + return value + + logger = CaptureLogger() + hydrator = PipelineHydrator(uri_readers={"mem": reader}, logger=logger) + + for uri in sources: + assert hydrator._fetch_component_from_uri(uri, "Pipeline.task") is None + + logs = logger.get_logs() or "" + assert "Component not found at URI mem://missing" in logs + assert "Failed to parse component YAML from mem://invalid" in logs + assert "Failed to parse YAML from mem://empty" in logs + assert "expected a mapping" in logs + assert "non-local URI is a template_file config" in logs + + strict = PipelineHydrator(uri_readers={"mem": reader}, error_policy="raise") + with pytest.raises(HydrationError, match="expected a mapping"): + strict._fetch_component_from_uri("mem://list", "Pipeline.task") + + with pytest.raises(HydrationError, match="Pipeline not found at URI mem://missing"): + strict.hydrate_file("mem://missing") + + +def test_pipeline_hydrator_url_dispatch_soft_fails_unexpected_errors(tmp_path: Path): + from tangle_cli.logger import CaptureLogger + from tangle_cli.pipeline_hydrator import HydrationError, PipelineHydrator + + component_path = tmp_path / "component.yaml" + component_path.write_text( + yaml.safe_dump({"name": "Local", "implementation": {"container": {"image": "x"}}}), + encoding="utf-8", + ) + + def failing_resolver(*_args): + raise RuntimeError("boom") + + logger = CaptureLogger() + hydrator = PipelineHydrator( + component_resolvers={"boom": failing_resolver}, + logger=logger, + ) + + digest, spec = hydrator._fetch_component_by_url("component.yaml", "Pipeline.task", tmp_path) + assert digest + assert spec["name"] == "Local" + + assert hydrator._fetch_component_by_url("boom://component", "Pipeline.task", tmp_path) is None + assert "Failed to fetch component from URL boom://component: boom" in (logger.get_logs() or "") + + strict = PipelineHydrator( + component_resolvers={"boom": failing_resolver}, + error_policy="raise", + ) + with pytest.raises(HydrationError, match="Failed to fetch component from URL"): + strict._fetch_component_by_url("boom://component", "Pipeline.task", tmp_path) + + +def test_pipeline_hydrator_postprocess_loaded_local_spec_hook(tmp_path: Path): + from tangle_cli import utils + from tangle_cli.logger import CaptureLogger + from tangle_cli.pipeline_hydrator import PipelineHydrator + + (tmp_path / "component.yaml.j2").write_text( + textwrap.dedent( + """ + name: "{{ name }}" + implementation: + container: + image: python:3.12 + """ + ), + encoding="utf-8", + ) + _write_pipeline( + tmp_path / "component-config.yaml", + {"template_file": "component.yaml.j2", "name": "Rendered"}, + ) + + calls = [] + + class HookedHydrator(PipelineHydrator): + def postprocess_loaded_local_spec( + self, + spec, + *, + file_path, + yaml_text, + rendered_from_template, + ): + calls.append( + { + "file_path": file_path, + "yaml_text": yaml_text, + "rendered_from_template": rendered_from_template, + "source_dir": spec.get("_source_dir"), + } + ) + updated = dict(spec) + updated["name"] = "Postprocessed" + updated["metadata"] = {"annotations": {"postprocessed": "true"}} + return updated + + logger = CaptureLogger() + hydrator = HookedHydrator(logger=logger) + + digest, spec = hydrator._fetch_component_from_file_url( + "file://./component-config.yaml", + "Pipeline.task", + tmp_path, + ) + + assert spec["name"] == "Postprocessed" + assert spec["metadata"]["annotations"]["postprocessed"] == "true" + assert len(calls) == 1 + assert calls[0]["file_path"] == tmp_path / "component-config.yaml" + assert calls[0]["rendered_from_template"] is True + assert calls[0]["source_dir"] == str(tmp_path) + assert "name: \"Rendered\"" in calls[0]["yaml_text"] + assert digest == utils.compute_text_digest(calls[0]["yaml_text"]) + assert "Loaded component: Postprocessed" in (logger.get_logs() or "") + + +def test_pipeline_hydrator_recursive_context_flows_to_child_templates(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator + + (tmp_path / "pipeline.yaml.j2").write_text( + textwrap.dedent( + """ + name: Pipeline + implementation: + graph: + tasks: + child: + componentRef: + url: file://./child-config.yaml + """ + ), + encoding="utf-8", + ) + (tmp_path / "child.yaml.j2").write_text( + textwrap.dedent( + """ + name: "Child {{ shared }} {{ parent_only }} {{ child_only }}" + implementation: + container: + image: python:3.12 + """ + ), + encoding="utf-8", + ) + _write_pipeline( + tmp_path / "pipeline-config.yaml", + { + "template_file": "pipeline.yaml.j2", + "shared": "parent", + "parent_only": "yes", + }, + ) + _write_pipeline( + tmp_path / "child-config.yaml", + { + "template_file": "child.yaml.j2", + "shared": "child", + "child_only": "also", + }, + ) + + parent_first = PipelineHydrator(recursive_context="parent-priority").hydrate_file( + tmp_path / "pipeline-config.yaml" + ) + child_first = PipelineHydrator(recursive_context="child-priority").hydrate_file( + tmp_path / "pipeline-config.yaml" + ) + + parent_spec = parent_first.data["implementation"]["graph"]["tasks"]["child"]["componentRef"]["spec"] + child_spec = child_first.data["implementation"]["graph"]["tasks"]["child"]["componentRef"]["spec"] + assert parent_spec["name"] == "Child parent yes also" + assert child_spec["name"] == "Child child yes also" + + +def test_pipeline_hydrator_recursive_context_does_not_leak_between_hydrates(tmp_path: Path): + from tangle_cli.pipeline_hydrator import PipelineHydrator + + (tmp_path / "first-pipeline.yaml.j2").write_text( + textwrap.dedent( + """ + name: First + implementation: + graph: + tasks: + child: + componentRef: + url: file://./first-child-config.yaml + """ + ), + encoding="utf-8", + ) + (tmp_path / "first-child.yaml.j2").write_text( + textwrap.dedent( + """ + name: "First {{ parent_only }}" + implementation: + container: + image: python:3.12 + """ + ), + encoding="utf-8", + ) + _write_pipeline( + tmp_path / "first-config.yaml", + {"template_file": "first-pipeline.yaml.j2", "parent_only": "leaked"}, + ) + _write_pipeline( + tmp_path / "first-child-config.yaml", + {"template_file": "first-child.yaml.j2"}, + ) + + (tmp_path / "plain-child.yaml.j2").write_text( + textwrap.dedent( + """ + name: "Plain {{ parent_only|default('missing') }} {{ child_only }}" + implementation: + container: + image: python:3.12 + """ + ), + encoding="utf-8", + ) + _write_pipeline( + tmp_path / "plain.yaml", + { + "name": "Plain", + "implementation": { + "graph": { + "tasks": { + "child": {"componentRef": {"url": "file://./plain-child-config.yaml"}} + } + } + }, + }, + ) + _write_pipeline( + tmp_path / "plain-child-config.yaml", + {"template_file": "plain-child.yaml.j2", "child_only": "second"}, + ) + + hydrator = PipelineHydrator(recursive_context="parent-priority") + hydrator.hydrate_file(tmp_path / "first-config.yaml") + second = hydrator.hydrate_file(tmp_path / "plain.yaml") + + child_spec = second.data["implementation"]["graph"]["tasks"]["child"]["componentRef"]["spec"] + assert child_spec["name"] == "Plain missing second" + assert hydrator._global_params == {} + + +def test_pipelines_layout_preserves_tasks_and_updates_coordinates(tmp_path: Path, capsys): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml", _minimal_valid_pipeline()) + output_path = tmp_path / "layout.yaml" + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipelines", + "layout", + str(pipeline_path), + "--output", + str(output_path), + ], + ) + + assert "Positioned 2 task" in capsys.readouterr().out + original = yaml.safe_load(pipeline_path.read_text(encoding="utf-8")) + updated = yaml.safe_load(output_path.read_text(encoding="utf-8")) + original_tasks = original["implementation"]["graph"]["tasks"] + updated_tasks = updated["implementation"]["graph"]["tasks"] + assert list(updated_tasks) == list(original_tasks) + assert updated_tasks["extract"]["componentRef"] == original_tasks["extract"]["componentRef"] + assert updated_tasks["load"]["componentRef"] == original_tasks["load"]["componentRef"] + + extract_position = json.loads(updated_tasks["extract"]["annotations"]["editor.position"]) + load_position = json.loads(updated_tasks["load"]["annotations"]["editor.position"]) + assert extract_position == {"x": 0, "y": 0} + assert load_position["x"] > extract_position["x"] diff --git a/tests/test_resource_managers.py b/tests/test_resource_managers.py new file mode 100644 index 0000000..db9eb9e --- /dev/null +++ b/tests/test_resource_managers.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import subprocess +import sys +import textwrap +from types import SimpleNamespace +from typing import Any + +import pytest + +from tangle_cli.artifacts import ArtifactManager +from tangle_cli.models import ArtifactInfo +from tangle_cli.pipeline_run_details import PipelineRunDetails +from tangle_cli.pipeline_run_search import PipelineRunSearch +from tangle_cli.secrets import SecretValueError, SecretsManager + + +class ArtifactClient: + def __init__(self) -> None: + self.calls: list[str] = [] + + def artifacts_get(self, artifact_id: str) -> dict[str, Any]: + self.calls.append(f"artifact:{artifact_id}") + return {"id": artifact_id, "artifact_data": {"uri": f"gs://bucket/{artifact_id}"}} + + def get_execution_details(self, execution_id: str) -> Any: + self.calls.append(f"execution:{execution_id}") + return SimpleNamespace(output_artifacts={"model": {"id": "artifact-model"}}) + + def get_run_details(self, run_id: str) -> Any: + self.calls.append(f"run:{run_id}") + return SimpleNamespace(execution=None) + + +def test_artifact_manager_lazy_factory_and_public_serialization() -> None: + client = ArtifactClient() + calls: list[str] = [] + + def factory() -> ArtifactClient: + calls.append("created") + return client + + manager = ArtifactManager(client_factory=factory) + assert calls == [] + + artifacts = manager.get_artifacts("run-1", {"artifact_ids": ["artifact-1"]}) + + assert calls == ["created"] + assert artifacts["artifact-1"].uri == "gs://bucket/artifact-1" + assert ArtifactManager.serialize_artifacts(artifacts) == [ + { + "id": "artifact-1", + "uri": "gs://bucket/artifact-1", + "key": "artifact-1", + "total_size": 0, + "is_dir": False, + } + ] + + +class SecretClient: + def __init__(self) -> None: + self.created: list[tuple[str, str]] = [] + + def secrets_list(self) -> SimpleNamespace: + return SimpleNamespace(secrets=[SimpleNamespace(secret_name="API_TOKEN", description="token")]) + + def secrets_create( + self, + secret_name: str, + secret_value: str, + description: str | None = None, + expires_at: str | None = None, + ) -> SimpleNamespace: + del expires_at + self.created.append((secret_name, secret_value)) + return SimpleNamespace(secret_name=secret_name, description=description) + + def secrets_update( + self, + secret_name: str, + secret_value: str, + description: str | None = None, + expires_at: str | None = None, + ) -> SimpleNamespace: + del secret_value, expires_at + return SimpleNamespace(secret_name=secret_name, description=description) + + def secrets_delete(self, secret_name: str) -> None: + del secret_name + + +def test_secrets_manager_methods_and_function_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: + client = SecretClient() + calls: list[str] = [] + + def factory() -> SecretClient: + calls.append("created") + return client + + manager = SecretsManager(client_factory=factory) + assert manager.list()["secrets"] == [{"secret_name": "API_TOKEN", "description": "token"}] + assert calls == ["created"] + + monkeypatch.setenv("SECRET_VALUE", "super-secret") + result = manager.create("NEW_SECRET", from_env="SECRET_VALUE", description="demo") + + assert result == { + "status": "success", + "action": "created", + "secret": {"secret_name": "NEW_SECRET", "description": "demo"}, + } + assert client.created == [("NEW_SECRET", "super-secret")] + with pytest.raises(SecretValueError): + SecretsManager.resolve_secret_value("inline", "SECRET_VALUE") + + +class SearchClient: + base_url = "https://tangle.example" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def users_me(self) -> SimpleNamespace: + return SimpleNamespace(id="user-1") + + def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: + self.calls.append(kwargs) + return { + "pipeline_runs": [ + { + "id": "run-1234567890", + "pipeline_name": "Daily Pulse", + "created_by": "user-1", + "created_at": "2026-01-01T00:00:00Z", + } + ], + "next_page_token": None, + } + + +def test_pipeline_run_search_class() -> None: + client = SearchClient() + manager = PipelineRunSearch(client=client) + + result = manager.search(name="pulse", created_by="me", limit=10) + + assert result["count"] == 1 + assert result["runs"][0]["run_url"] == "https://tangle.example/runs/run-1234567890" + assert "system/pipeline_run.name" in client.calls[0]["filter_query"] + assert "user-1" in client.calls[0]["filter_query"] + assert manager.build_filter_query(name="pulse") == { + "and": [{"value_contains": {"key": "system/pipeline_run.name", "value_substring": "pulse"}}] + } + + + +def test_pipeline_run_search_lazy_factory() -> None: + client = SearchClient() + calls: list[str] = [] + + def factory() -> SearchClient: + calls.append("created") + return client + + manager = PipelineRunSearch(client_factory=factory) + assert calls == [] + assert manager.search(query={"and": []}, limit=1)["count"] == 1 + assert calls == ["created"] + + +class DetailsClient: + def __init__(self) -> None: + self.details_kwargs: dict[str, Any] | None = None + + def get_run_details(self, run_id: str, **kwargs: Any) -> SimpleNamespace: + self.details_kwargs = kwargs + return SimpleNamespace( + run=SimpleNamespace( + id=run_id, + root_execution_id="exec-root", + created_at="2026-01-01T00:00:00Z", + created_by="user-1", + annotations={"k": "v"}, + ), + execution=None, + annotations={"extra": "yes"}, + execution_state=None, + ) + + def pipeline_runs_get(self, run_id: str) -> SimpleNamespace: + return SimpleNamespace(id=run_id, root_execution_id="exec-root") + + def executions_graph_execution_state(self, root_execution_id: str) -> SimpleNamespace: + return SimpleNamespace( + status_totals={"SUCCEEDED": 1}, + failed_execution_ids=[], + per_execution={root_execution_id: {"SUCCEEDED": 1}}, + ) + + +def test_pipeline_run_details_class() -> None: + client = DetailsClient() + manager = PipelineRunDetails(client=client) + + details = manager.get_run_details_output("run-1", include_annotations=True, execution_id="exec-1") + + assert details["run"]["id"] == "run-1" + assert details["annotations"] == {"extra": "yes"} + assert client.details_kwargs == { + "include_annotations": True, + "include_execution_state": False, + "execution_id": "exec-1", + } + + graph = manager.get_graph_state_output(["run-1"]) + assert graph["results"][0]["status_totals"] == {"SUCCEEDED": 1} + + +def test_pipeline_run_details_lazy_factory() -> None: + client = DetailsClient() + calls: list[str] = [] + + def factory() -> DetailsClient: + calls.append("created") + return client + + manager = PipelineRunDetails(client_factory=factory) + assert calls == [] + assert manager.get_graph_state_output(["run-1"])["results"][0]["error"] is None + assert calls == ["created"] + + +def test_resource_manager_modules_import_without_native_tangle_api() -> None: + code = r''' +import builtins + +original_import = builtins.__import__ + +def guarded_import(name, *args, **kwargs): + if name == "tangle_api" or name.startswith("tangle_api."): + raise ModuleNotFoundError("blocked native tangle_api import") + return original_import(name, *args, **kwargs) + +builtins.__import__ = guarded_import + +from tangle_cli.artifacts import ArtifactComponentQuery, ArtifactInfo, ArtifactManager +from tangle_cli.secrets import SecretsManager +from tangle_cli.pipeline_run_search import PipelineRunSearch +from tangle_cli.pipeline_run_details import PipelineRunDetails +from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks + +assert ArtifactComponentQuery is not None +assert ArtifactInfo(id="a", uri="u").uri == "u" +assert ArtifactManager is not None +assert SecretsManager is not None +assert PipelineRunSearch is not None +assert PipelineRunDetails is not None +assert PipelineRunner is not None +assert PipelineRunnerHooks is not None +''' + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_resource_manager_import_surface() -> None: + from tangle_cli.artifacts import ArtifactManager as ImportedArtifactManager + from tangle_cli.pipeline_run_details import PipelineRunDetails as ImportedPipelineRunDetails + from tangle_cli.pipeline_run_search import PipelineRunSearch as ImportedPipelineRunSearch + from tangle_cli.pipeline_runner import PipelineRunner as ImportedPipelineRunner + from tangle_cli.secrets import SecretsManager as ImportedSecretsManager + + assert ImportedArtifactManager is ArtifactManager + assert ArtifactManager.serialize_artifacts({"a": ArtifactInfo(id="a", uri="u", key="a")})[0]["id"] == "a" + assert ImportedSecretsManager is SecretsManager + assert ImportedPipelineRunSearch is PipelineRunSearch + assert ImportedPipelineRunDetails is PipelineRunDetails + assert ImportedPipelineRunner is not None diff --git a/tests/test_secrets_cli.py b/tests/test_secrets_cli.py new file mode 100644 index 0000000..6072131 --- /dev/null +++ b/tests/test_secrets_cli.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import builtins +import importlib +import json +import sys +from types import SimpleNamespace +from typing import Any + +import pytest + +from tangle_cli import cli, secrets_cli + + +def run_app(app: Any, args: list[str]) -> None: + try: + app(args) + except SystemExit as exc: + if exc.code not in (0, None): + raise + + +class FakeLazyTangleApiClient: + instances: list["FakeLazyTangleApiClient"] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.calls: list[dict[str, Any]] = [] + FakeLazyTangleApiClient.instances.append(self) + + def secrets_list(self) -> SimpleNamespace: + self.calls.append({"method": "secrets_list"}) + return SimpleNamespace( + secrets=[ + { + "secret_name": "API_TOKEN", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "expires_at": None, + "description": "token for API", + "secret_value": "must-not-leak", + } + ] + ) + + def secrets_create( + self, + secret_name: str, + secret_value: str, + *, + description: str | None = None, + expires_at: str | None = None, + ) -> SimpleNamespace: + self.calls.append( + { + "method": "secrets_create", + "secret_name": secret_name, + "secret_value": secret_value, + "description": description, + "expires_at": expires_at, + } + ) + return SimpleNamespace( + secret_name=secret_name, + created_at="2026-01-01T00:00:00Z", + updated_at="2026-01-01T00:00:00Z", + expires_at=expires_at, + description=description, + ) + + def secrets_update( + self, + secret_name: str, + secret_value: str, + *, + description: str | None = None, + expires_at: str | None = None, + ) -> SimpleNamespace: + self.calls.append( + { + "method": "secrets_update", + "secret_name": secret_name, + "secret_value": secret_value, + "description": description, + "expires_at": expires_at, + } + ) + return SimpleNamespace( + secret_name=secret_name, + created_at="2026-01-01T00:00:00Z", + updated_at="2026-01-03T00:00:00Z", + expires_at=expires_at, + description=description, + ) + + def secrets_delete(self, secret_name: str) -> None: + self.calls.append({"method": "secrets_delete", "secret_name": secret_name}) + + +@pytest.fixture(autouse=True) +def fake_lazy_client(monkeypatch: pytest.MonkeyPatch) -> None: + FakeLazyTangleApiClient.instances = [] + monkeypatch.setattr(secrets_cli, "LazyTangleApiClient", FakeLazyTangleApiClient) + + +def test_sdk_secrets_help_lists_read_write_commands(capsys: pytest.CaptureFixture[str]) -> None: + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "--help"]) + + output = capsys.readouterr().out + assert "list" in output + assert "create" in output + assert "update" in output + assert "delete" in output + + +def test_sdk_secrets_list_prints_metadata_without_values(capsys: pytest.CaptureFixture[str]) -> None: + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "list"]) + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result == { + "status": "success", + "count": 1, + "secrets": [ + { + "secret_name": "API_TOKEN", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "description": "token for API", + } + ], + } + assert "must-not-leak" not in captured.out + assert "must-not-leak" not in captured.err + assert FakeLazyTangleApiClient.instances[0].calls == [{"method": "secrets_list"}] + + +def test_sdk_secrets_create_with_value_calls_generated_operation_without_leaking_value( + capsys: pytest.CaptureFixture[str], +) -> None: + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "secrets", + "create", + "API_TOKEN", + "--value", + "super-secret", + "--description", + "demo", + ], + ) + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["status"] == "success" + assert result["action"] == "created" + assert result["secret"]["secret_name"] == "API_TOKEN" + assert result["secret"]["description"] == "demo" + assert "super-secret" not in captured.out + assert "super-secret" not in captured.err + assert FakeLazyTangleApiClient.instances[0].calls == [ + { + "method": "secrets_create", + "secret_name": "API_TOKEN", + "secret_value": "super-secret", + "description": "demo", + "expires_at": None, + } + ] + + +def test_sdk_secrets_create_with_from_env_resolves_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TANGLE_SECRET_VALUE", "from-env-secret") + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "create", "API_TOKEN", "--from-env", "TANGLE_SECRET_VALUE"]) + + assert FakeLazyTangleApiClient.instances[0].calls[0]["secret_value"] == "from-env-secret" + + +@pytest.mark.parametrize( + ("args", "message"), + [ + ( + ["sdk", "secrets", "create", "API_TOKEN", "--value", "secret", "--from-env", "SECRET_ENV"], + "specify either --value or --from-env", + ), + (["sdk", "secrets", "create", "API_TOKEN"], "either --value or --from-env is required"), + ( + ["sdk", "secrets", "create", "API_TOKEN", "--from-env", "MISSING_SECRET_ENV"], + "environment variable 'MISSING_SECRET_ENV' is not set", + ), + ], +) +def test_sdk_secrets_create_value_validation_errors_do_not_leak_values(args: list[str], message: str) -> None: + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(args) + + assert message in str(exc_info.value) + assert "secret" not in str(exc_info.value).replace("--from-env", "") + assert not FakeLazyTangleApiClient.instances or FakeLazyTangleApiClient.instances[0].calls == [] + + +def test_sdk_secrets_update_with_from_env_calls_generated_operation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UPDATED_SECRET", "updated-secret") + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "secrets", + "update", + "API_TOKEN", + "--from-env", + "UPDATED_SECRET", + "--expires-at", + "2026-12-31T00:00:00Z", + ], + ) + + assert FakeLazyTangleApiClient.instances[0].calls == [ + { + "method": "secrets_update", + "secret_name": "API_TOKEN", + "secret_value": "updated-secret", + "description": None, + "expires_at": "2026-12-31T00:00:00Z", + } + ] + + +def test_sdk_secrets_update_rejects_missing_value() -> None: + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "secrets", "update", "API_TOKEN"]) + + assert "either --value or --from-env is required" in str(exc_info.value) + + +def test_sdk_secrets_delete_prompts_unless_forced( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(builtins, "input", lambda: "n") + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "secrets", "delete", "API_TOKEN"]) + + captured = capsys.readouterr() + assert "Delete cancelled" in str(exc_info.value) + assert "Are you sure you want to delete secret 'API_TOKEN'? [y/N]: " in captured.err + assert "Are you sure" not in captured.out + assert FakeLazyTangleApiClient.instances[0].calls == [] + + +def test_sdk_secrets_delete_confirmed_prompt_goes_to_stderr_and_stdout_is_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(builtins, "input", lambda: "yes") + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "delete", "API_TOKEN"]) + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result == {"status": "success", "action": "deleted", "secret_name": "API_TOKEN"} + assert "Are you sure you want to delete secret 'API_TOKEN'? [y/N]: " in captured.err + assert "Are you sure" not in captured.out + assert FakeLazyTangleApiClient.instances[0].calls == [ + {"method": "secrets_delete", "secret_name": "API_TOKEN"} + ] + + +def test_sdk_secrets_delete_force_calls_generated_operation_without_prompt(capsys: pytest.CaptureFixture[str]) -> None: + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "delete", "API_TOKEN", "--force"]) + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result == {"status": "success", "action": "deleted", "secret_name": "API_TOKEN"} + assert "Are you sure" not in captured.out + assert "Are you sure" not in captured.err + assert FakeLazyTangleApiClient.instances[0].calls == [ + {"method": "secrets_delete", "secret_name": "API_TOKEN"} + ] + + +def test_sdk_secrets_config_array_and_config_base_url_credential_isolation( + monkeypatch: pytest.MonkeyPatch, + tmp_path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("SECOND_SECRET", "second-secret") + config = tmp_path / "secrets.yaml" + config.write_text( + "_defaults:\n" + " base_url: https://config.example\n" + " token: config-token\n" + " auth_header: Bearer config-auth\n" + " header:\n" + " - 'X-Config: yes'\n" + "configs:\n" + " - secret_name: FIRST_SECRET\n" + " value: first-secret\n" + " description: first\n" + " - secret_name: SECOND_SECRET\n" + " from_env: SECOND_SECRET\n" + " description: second\n", + encoding="utf-8", + ) + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "create", "--config", str(config)]) + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["status"] == "success" + assert len(result["results"]) == 2 + assert "first-secret" not in captured.out + assert "second-secret" not in captured.out + assert [instance.kwargs for instance in FakeLazyTangleApiClient.instances] == [ + { + "base_url": "https://config.example", + "token": "config-token", + "auth_header": "Bearer config-auth", + "header": ["X-Config: yes"], + "include_env_credentials": False, + "command_name": "secret commands", + }, + { + "base_url": "https://config.example", + "token": "config-token", + "auth_header": "Bearer config-auth", + "header": ["X-Config: yes"], + "include_env_credentials": False, + "command_name": "secret commands", + }, + ] + assert [instance.calls[0]["secret_name"] for instance in FakeLazyTangleApiClient.instances] == [ + "FIRST_SECRET", + "SECOND_SECRET", + ] + assert [instance.calls[0]["secret_value"] for instance in FakeLazyTangleApiClient.instances] == [ + "first-secret", + "second-secret", + ] + + +def test_sdk_secrets_cli_base_url_keeps_env_credentials(tmp_path) -> None: + config = tmp_path / "secrets.yaml" + config.write_text("secret_name: API_TOKEN\nvalue: secret\nbase_url: https://config.example\n", encoding="utf-8") + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "secrets", + "create", + "--config", + str(config), + "--base-url", + "https://cli.example", + ], + ) + + assert FakeLazyTangleApiClient.instances[0].kwargs["base_url"] == "https://cli.example" + assert FakeLazyTangleApiClient.instances[0].kwargs["include_env_credentials"] is True + + +@pytest.mark.parametrize("log_type", ["console", "none", "file"]) +def test_sdk_secrets_log_type_option_works_without_leaking_values( + log_type: str, + capsys: pytest.CaptureFixture[str], +) -> None: + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "create", "API_TOKEN", "--value", "super-secret", "--log-type", log_type]) + + captured = capsys.readouterr() + assert "super-secret" not in captured.out + assert "super-secret" not in captured.err + assert json.loads(captured.out)["status"] == "success" + + +def test_secrets_cli_imports_without_native_api(monkeypatch: pytest.MonkeyPatch) -> None: + for name in list(sys.modules): + if name == "tangle_cli.secrets_cli" or name.startswith("tangle_api"): + del sys.modules[name] + + original_import = builtins.__import__ + + def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "tangle_api" or name.startswith("tangle_api."): + raise AssertionError(f"unexpected native API import: {name}") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + module = importlib.import_module("tangle_cli.secrets_cli") + + assert module.app.name == ("secrets",) diff --git a/tests/test_static_client.py b/tests/test_static_client.py new file mode 100644 index 0000000..602df26 --- /dev/null +++ b/tests/test_static_client.py @@ -0,0 +1,811 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest +import requests + +from tangle_cli.client import TangleApiClient +from tangle_cli.logger import CaptureLogger +from tangle_api.generated.models import ( + GetExecutionInfoResponse, + GetGraphExecutionStateResponse, + ListPublishedComponentsResponse, + PipelineRunResponse, + PublishedComponentResponse, + SecretInfoResponse, +) +from tangle_cli.models import ComponentSpec + + +def response(payload: Any = None, status_code: int = 200) -> requests.Response: + r = requests.Response() + r.status_code = status_code + if payload is None: + r._content = b"" + else: + r._content = json.dumps(payload).encode("utf-8") + r.headers["Content-Type"] = "application/json" + r.request = requests.Request("GET", "https://api.test").prepare() + return r + + +class FakeSession: + def __init__(self, responses: list[requests.Response] | None = None) -> None: + self.calls: list[dict[str, Any]] = [] + self.responses = responses or [] + + def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: + self.calls.append({"method": method, "url": url, **kwargs}) + if self.responses: + return self.responses.pop(0) + return response({}) + + +def test_generated_graph_state_response_extensions_work_at_runtime() -> None: + state = GetGraphExecutionStateResponse.from_dict({ + "child_execution_status_stats": { + "exec-1": {"SUCCEEDED": 2, "FAILED": 1}, + "exec-2": {"SYSTEM_ERROR": 1}, + } + }) + + assert state.per_execution == { + "exec-1": {"SUCCEEDED": 2, "FAILED": 1}, + "exec-2": {"SYSTEM_ERROR": 1}, + } + assert state.status_totals == {"SUCCEEDED": 2, "FAILED": 1, "SYSTEM_ERROR": 1} + assert state.failed_execution_ids == ["exec-1", "exec-2"] + + + +@pytest.mark.parametrize("value", [None, "0", "false"]) +def test_static_client_does_not_log_bodies_when_verbose_false( + monkeypatch: pytest.MonkeyPatch, + value: str | None, +) -> None: + if value is None: + monkeypatch.delenv("TANGLE_VERBOSE", raising=False) + else: + monkeypatch.setenv("TANGLE_VERBOSE", value) + logger = CaptureLogger() + session = FakeSession([response({"token": "response-secret"})]) + client = TangleApiClient("https://api.test", session=session, logger=logger) + + client._make_request( + "POST", + "/api/pipeline_runs/", + json_data={"token": "request-secret", "name": "demo"}, + ) + + assert logger.get_logs() is None + + +def test_static_client_verbose_env_logs_redacted_exchange(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TANGLE_VERBOSE", "1") + logger = CaptureLogger() + session = FakeSession([response({"id": "run-1", "token": "response-secret"})]) + client = TangleApiClient( + "https://api.test", + session=session, + logger=logger, + auth_header="Bearer request-secret", + header=["Cloud-Auth: cloud-secret", "X-Api-Key: api-secret"], + ) + + client._make_request( + "POST", + "/api/pipeline_runs/", + json_data={"name": "demo", "token": "request-secret"}, + ) + + logs = logger.get_logs() or "" + assert "[tangle-api] request: POST https://api.test/api/pipeline_runs/" in logs + assert "request body" in logs + assert "response body" in logs + assert "demo" in logs + assert "run-1" in logs + assert "request-secret" not in logs + assert "response-secret" not in logs + assert "cloud-secret" not in logs + assert "api-secret" not in logs + assert "" in logs + + +def test_public_static_client_import_and_generated_operation() -> None: + session = FakeSession([ + response({"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}) + ]) + client = TangleApiClient("https://api.test", session=session) + + run = client.pipeline_runs_get("run/1") + + assert isinstance(run, PipelineRunResponse) + assert run["id"] == "run-1" + assert session.calls[0]["method"] == "GET" + assert session.calls[0]["url"] == "https://api.test/api/pipeline_runs/run%2F1" + + +def test_request_json_instantiates_list_response_models() -> None: + session = FakeSession([ + response([{"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}]) + ]) + client = TangleApiClient("https://api.test", session=session) + + runs = client._request_json("GET", "/api/pipeline_runs/", response_model=PipelineRunResponse) + + assert isinstance(runs, list) + assert isinstance(runs[0], PipelineRunResponse) + assert runs[0].id == "run-1" + + +def test_static_client_rejects_absolute_paths_before_request() -> None: + session = FakeSession() + client = TangleApiClient("https://api.test", session=session) + + with pytest.raises(ValueError, match="must be relative"): + client._make_request("GET", "https://attacker.example/collect") + + assert session.calls == [] + + +def test_static_client_rejects_network_path_references_before_request() -> None: + session = FakeSession() + client = TangleApiClient("https://api.test", session=session) + + with pytest.raises(ValueError, match="must be relative"): + client._make_request("GET", "//attacker.example/collect") + + assert session.calls == [] + + +def test_cross_origin_redirect_is_rejected() -> None: + redirect = response(status_code=307) + redirect.url = "https://api.test/api/secrets" + redirect.headers["Location"] = "https://attacker.example/leak" + session = FakeSession([redirect]) + client = TangleApiClient("https://api.test", session=session) + + with pytest.raises(requests.HTTPError, match="cross-origin redirect") as exc_info: + client._make_request("POST", "/api/secrets", json_data={"secret_value": "sensitive"}) + + assert exc_info.value.response is redirect + assert session.calls[0]["allow_redirects"] is False + + +def test_same_origin_redirect_is_followed() -> None: + redirect = response(status_code=307) + redirect.url = "https://api.test/api/old" + redirect.headers["Location"] = "/api/new" + ok = response({"ok": True}) + ok.url = "https://api.test/api/new" + session = FakeSession([redirect, ok]) + client = TangleApiClient("https://api.test", session=session) + + result = client._make_request("POST", "/api/old", json_data={"a": 1}) + + assert result is ok + assert len(session.calls) == 2 + assert session.calls[1]["method"] == "POST" + assert session.calls[1]["url"] == "https://api.test/api/new" + assert session.calls[1]["params"] is None + assert session.calls[1]["json"] == {"a": 1} + + +def test_rate_limit_response_is_retried(monkeypatch) -> None: + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.client.time.sleep", sleeps.append) + rate_limited = response(status_code=429) + rate_limited.headers["Retry-After"] = "0" + ok = response({"ok": True}) + session = FakeSession([rate_limited, ok]) + client = TangleApiClient("https://api.test", session=session) + + result = client._make_request("GET", "/api/test") + + assert result is ok + assert len(session.calls) == 2 + assert sleeps == [0.0] + + +def test_numeric_retry_after_is_capped(monkeypatch) -> None: + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.client.time.sleep", sleeps.append) + rate_limited = response(status_code=429) + rate_limited.headers["Retry-After"] = "999" + ok = response({"ok": True}) + session = FakeSession([rate_limited, ok]) + client = TangleApiClient("https://api.test", session=session) + + client._make_request("GET", "/api/test") + + assert sleeps == [client._MAX_RETRY_AFTER_SECONDS] + + +def test_http_date_retry_after_is_capped(monkeypatch) -> None: + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.client.time.sleep", sleeps.append) + monkeypatch.setattr("tangle_cli.client.time.time", lambda: 0.0) + rate_limited = response(status_code=429) + rate_limited.headers["Retry-After"] = "Wed, 21 Oct 2037 07:28:00 GMT" + ok = response({"ok": True}) + session = FakeSession([rate_limited, ok]) + client = TangleApiClient("https://api.test", session=session) + + client._make_request("GET", "/api/test") + + assert sleeps == [client._MAX_RETRY_AFTER_SECONDS] + + +def test_dumb_compat_wrappers_are_removed_but_semantic_helpers_remain() -> None: + removed = [ + "get_artifact", + "get_artifact_signed_url", + "get_execution_graph_state", + "get_execution_graph_state_alt", + "get_execution_container_state", + "get_execution_artifacts", + "get_execution_container_log", + "list_pipeline_runs", + "create_pipeline_run", + "get_pipeline_run", + "cancel_pipeline_run", + "list_pipeline_run_annotations", + "set_pipeline_run_annotation", + "delete_pipeline_run_annotation", + "get_current_user", + "get_component", + "list_published_components", + "publish_component", + "update_published_component", + "list_secrets", + "create_secret", + "update_secret", + "delete_secret", + "get_component_search_schema", + "search_components_v2", + ] + for name in removed: + assert not hasattr(TangleApiClient, name) + + retained = [ + "resolve_digest", + "get_run_details", + "get_run_pipeline_spec", + "get_execution_details", + "_enrich_execution_tree", + "find_existing_components", + "list_published_component_infos", + "get_component_spec", + "stream_execution_container_log", + ] + for name in retained: + assert hasattr(TangleApiClient, name) + + +def test_get_run_details_uses_native_operations_for_retained_semantic_helper() -> None: + session = FakeSession([ + response({"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}), + response({ + "id": "exec-1", + "pipeline_run_id": "run-1", + "task_spec": {}, + "input_artifacts": {}, + "output_artifacts": {}, + }), + response({"owner": "alice"}), + response({"child_execution_status_stats": {"exec-1": {"SUCCEEDED": 1}}}), + ]) + client = TangleApiClient("https://api.test", session=session) + + details = client.get_run_details( + "run-1", + include_annotations=True, + include_execution_state=True, + ) + + assert details.run.id == "run-1" + assert details.execution is not None + assert isinstance(details.execution, GetExecutionInfoResponse) + assert details.execution.id == "exec-1" + assert details.annotations == {"owner": "alice"} + assert details.execution_state is not None + assert details.execution_state.status_totals == {"SUCCEEDED": 1} + assert [call["url"] for call in session.calls] == [ + "https://api.test/api/pipeline_runs/run-1", + "https://api.test/api/executions/exec-1/details", + "https://api.test/api/pipeline_runs/run-1/annotations/", + "https://api.test/api/executions/exec-1/graph_execution_state", + ] + + +def test_get_run_details_falls_back_to_run_id_as_root_execution_after_404() -> None: + not_found = response(status_code=404) + execution_payload = { + "id": "root-exec", + "task_spec": {"componentRef": {"spec": {"name": "pipeline"}}}, + "child_task_execution_ids": {}, + "input_artifacts": {}, + "output_artifacts": {}, + } + session = FakeSession([not_found, response(execution_payload)]) + client = TangleApiClient("https://api.test", session=session) + + details = client.get_run_details("root-exec") + + assert details.run.id == "root-exec" + assert details.run.root_execution_id == "root-exec" + assert details.execution is not None + assert details.execution.id == "root-exec" + assert [call["url"] for call in session.calls] == [ + "https://api.test/api/pipeline_runs/root-exec", + "https://api.test/api/executions/root-exec/details", + ] + + +def test_get_run_details_fallback_can_include_execution_state() -> None: + not_found = response(status_code=404) + execution_payload = { + "id": "root-exec", + "task_spec": {"componentRef": {"spec": {"name": "pipeline"}}}, + "child_task_execution_ids": {}, + "input_artifacts": {}, + "output_artifacts": {}, + } + session = FakeSession([ + not_found, + response(execution_payload), + response({"child_execution_status_stats": {"root-exec": {"SUCCEEDED": 1}}}), + ]) + client = TangleApiClient("https://api.test", session=session) + + details = client.get_run_details("root-exec", include_execution_state=True) + + assert details.execution_state is not None + assert details.execution_state.status_totals == {"SUCCEEDED": 1} + assert session.calls[-1]["url"] == "https://api.test/api/executions/root-exec/graph_execution_state" + + +def graph_execution_payload() -> dict[str, Any]: + return { + "id": "exec-parent", + "pipeline_run_id": "run-1", + "task_spec": { + "componentRef": { + "spec": { + "name": "root", + "implementation": { + "graph": { + "tasks": { + "child": { + "componentRef": { + "digest": "sha256:child", + "text": "name: child\nimplementation: bulky\n", + "spec": { + "name": "child-placeholder", + "metadata": { + "annotations": { + "python_original_code": "print('x')", + "keep": "yes", + } + }, + "implementation": { + "container": {"image": "placeholder"} + }, + }, + } + } + } + } + }, + } + } + }, + "child_task_execution_ids": {"child": "exec-child"}, + "input_artifacts": {}, + "output_artifacts": {}, + } + + +def child_execution_payload() -> dict[str, Any]: + return { + "id": "exec-child", + "pipeline_run_id": "run-1", + "state": "SUCCEEDED", + "task_spec": { + "componentRef": { + "spec": { + "name": "child-real", + "implementation": { + "container": {"image": "python:3.12-slim"} + }, + } + } + }, + "child_task_execution_ids": {}, + "input_artifacts": {"input": {"id": "artifact-in"}}, + "output_artifacts": {"output": {"id": "artifact-out"}}, + } + + +def test_get_run_details_enriches_raw_graph_tasks_and_strips_compact_output() -> None: + session = FakeSession([ + response({"id": "run-1", "root_execution_id": "exec-parent"}), + response(graph_execution_payload()), + response(child_execution_payload()), + ]) + client = TangleApiClient("https://api.test", session=session) + + details = client.get_run_details("run-1") + + execution = details.execution + assert isinstance(execution, GetExecutionInfoResponse) + task = execution.tasks["child"] + raw_task = execution.raw["task_spec"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["child"] + + expected_context = { + "execution_id": "exec-child", + "input_artifacts": {"input": "artifact-in"}, + "output_artifacts": {"output": "artifact-out"}, + "state": "SUCCEEDED", + } + for key, value in expected_context.items(): + assert task.raw[key] == value + assert raw_task[key] == value + + assert "text" not in raw_task["componentRef"] + raw_spec = raw_task["componentRef"]["spec"] + assert "implementation" not in raw_spec + assert raw_spec["metadata"]["annotations"] == {"keep": "yes"} + assert "text" not in task.raw["componentRef"] + assert "implementation" not in task.raw["componentRef"]["spec"] + assert execution.child_executions["child"].id == "exec-child" + + +def test_get_run_details_preserves_raw_graph_implementations_when_requested() -> None: + session = FakeSession([ + response({"id": "run-1", "root_execution_id": "exec-parent"}), + response(graph_execution_payload()), + response(child_execution_payload()), + ]) + client = TangleApiClient("https://api.test", session=session) + + details = client.get_run_details("run-1", include_implementations=True) + + execution = details.execution + assert isinstance(execution, GetExecutionInfoResponse) + raw_task = execution.raw["task_spec"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["child"] + raw_spec = raw_task["componentRef"]["spec"] + + assert raw_task["componentRef"]["text"] == "name: child\nimplementation: bulky\n" + assert raw_spec["implementation"] == {"container": {"image": "python:3.12-slim"}} + assert raw_task["execution_id"] == "exec-child" + assert raw_task["input_artifacts"] == {"input": "artifact-in"} + assert raw_task["output_artifacts"] == {"output": "artifact-out"} + assert raw_task["state"] == "SUCCEEDED" + assert execution.tasks["child"].raw["execution_id"] == "exec-child" + + +def test_published_component_create_omits_unset_optional_body_fields() -> None: + session = FakeSession([ + response({ + "digest": "digest-1", + "name": "Demo", + "url": "https://example.test/component.yaml", + }) + ]) + client = TangleApiClient("https://api.test", session=session) + + published = client.published_components_create( + name="Demo", + url="https://example.test/component.yaml", + ) + + assert isinstance(published, PublishedComponentResponse) + assert published.name == "Demo" + assert session.calls[0]["method"] == "POST" + assert session.calls[0]["url"] == "https://api.test/api/published_components/" + assert session.calls[0]["json"] == { + "name": "Demo", + "url": "https://example.test/component.yaml", + } + assert "digest" not in session.calls[0]["json"] + + +def test_secret_native_operation_uses_static_generated_endpoint_shape() -> None: + session = FakeSession([ + response({ + "secret_name": "demo", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "description": "d", + }) + ]) + client = TangleApiClient("https://api.test", session=session) + + secret = client.secrets_create("demo", "value", description="d") + + assert isinstance(secret, SecretInfoResponse) + assert secret.secret_name == "demo" + assert session.calls[0]["method"] == "POST" + assert session.calls[0]["url"] == "https://api.test/api/secrets/" + assert session.calls[0]["params"] == {"secret_name": "demo", "description": "d"} + assert session.calls[0]["json"] == {"secret_value": "value"} + + +class ResolveDigestClient(TangleApiClient): + def __init__( + self, + by_digest: dict[str, list[Any]] | None = None, + by_name: dict[str, list[Any]] | None = None, + ) -> None: + super().__init__("https://api.test") + self.by_digest = by_digest or {} + self.by_name = by_name or {} + self.lookups: list[dict[str, Any]] = [] + + def published_components_list( + self, + include_deprecated: bool = False, + name_substring: str | None = None, + published_by_substring: str | None = None, + digest: str | None = None, + ) -> ListPublishedComponentsResponse: + self.lookups.append({ + "include_deprecated": include_deprecated, + "name_substring": name_substring, + "published_by_substring": published_by_substring, + "digest": digest, + }) + if digest is not None: + rows = self.by_digest.get(digest, []) + elif name_substring is not None: + rows = self.by_name.get(name_substring, []) + else: + rows = [] + return ListPublishedComponentsResponse.from_dict({"published_components": rows}) + + +def test_find_existing_components_returns_deduped_list_with_filters() -> None: + client = ResolveDigestClient( + by_digest={ + "sha256:one": [{ + "digest": "sha256:one", + "name": "demo", + "published_by": "alice@example.com", + }], + "sha256:two": [{ + "digest": "sha256:two", + "name": "by-digest", + "published_by": "alice@example.com", + }], + "sha256:spec": [{ + "digest": "sha256:spec", + "name": "spec-name", + "published_by": "alice@example.com", + }], + }, + by_name={ + "demo": [ + { + "digest": "sha256:one", + "name": "demo", + "published_by": "alice@example.com", + }, + { + "digest": "sha256:other", + "name": "not-demo", + "published_by": "alice@example.com", + }, + ], + "mapped-name": [{ + "digest": "sha256:mapped", + "name": "mapped-name", + "published_by": "alice@example.com", + }], + "explicit-name": [{ + "digest": "sha256:explicit", + "name": "explicit-name", + "published_by": "alice@example.com", + }], + "spec-name": [{ + "digest": "sha256:spec", + "name": "spec-name", + "published_by": "alice@example.com", + }], + "[Official] spec-name": [{ + "digest": "sha256:spec", + "name": "[Official] spec-name", + "published_by": "alice@example.com", + }], + }, + ) + logger = CaptureLogger() + client.logger = logger + + matches = client.find_existing_components( + [ + "demo", + {"name": "mapped-name", "digest": "sha256:one"}, + ComponentSpec(name="spec-name", digest="sha256:spec"), + ], + names=["explicit-name"], + digests=["sha256:two"], + include_deprecated=True, + published_by="alice@example.com", + verbose=True, + ) + + assert {match.digest for match in matches} == { + "sha256:one", + "sha256:two", + "sha256:spec", + "sha256:mapped", + "sha256:explicit", + } + assert all(match.published_by == "alice@example.com" for match in matches) + assert { + ( + lookup["include_deprecated"], + lookup["published_by_substring"], + lookup["digest"], + lookup["name_substring"], + ) + for lookup in client.lookups + } == { + (True, "alice@example.com", "sha256:one", None), + (True, "alice@example.com", "sha256:two", None), + (True, "alice@example.com", "sha256:spec", None), + (True, "alice@example.com", None, "demo"), + (True, "alice@example.com", None, "mapped-name"), + (True, "alice@example.com", None, "explicit-name"), + (True, "alice@example.com", None, "spec-name"), + (True, "alice@example.com", None, "[Official] spec-name"), + } + assert "Found existing component" in (logger.get_logs() or "") + + +def test_find_existing_components_prefers_published_by_substring() -> None: + client = ResolveDigestClient(by_name={ + "demo": [{ + "digest": "sha256:one", + "name": "demo", + "published_by": "bob@example.com", + }], + }) + + matches = client.find_existing_components( + ["demo"], + published_by="alice@example.com", + published_by_substring="bob@example.com", + ) + + assert [match.name for match in matches] == ["demo"] + assert client.lookups == [{ + "include_deprecated": False, + "name_substring": "demo", + "published_by_substring": "bob@example.com", + "digest": None, + }] + + +def test_resolve_digest_returns_non_deprecated_digest() -> None: + component = PublishedComponentResponse.from_dict({ + "digest": "sha256:one", + "published_by": "alice@example.com", + "deprecated": False, + }) + client = ResolveDigestClient(by_digest={"sha256:one": [component]}) + + assert client.resolve_digest("sha256:one") == "sha256:one" + assert client.lookups == [{ + "include_deprecated": True, + "name_substring": None, + "published_by_substring": None, + "digest": "sha256:one", + }] + + +def test_resolve_digest_follows_deprecation_successor_chain() -> None: + client = ResolveDigestClient( + by_digest={ + "sha256:old": [{ + "digest": "sha256:old", + "deprecated": True, + "superseded_by": "sha256:mid", + }], + "sha256:mid": [{ + "digest": "sha256:mid", + "deprecated": True, + "superseded_by": "new-component", + }], + }, + by_name={ + "new-component": [{ + "digest": "sha256:new", + "deprecated": False, + }], + }, + ) + + assert client.resolve_digest("sha256:old") == "sha256:new" + + +def test_resolve_digest_protects_against_successor_cycles() -> None: + client = ResolveDigestClient( + by_digest={ + "sha256:old": [{ + "digest": "sha256:old", + "deprecated": True, + "superseded_by": "sha256:next", + }], + "sha256:next": [{ + "digest": "sha256:next", + "deprecated": True, + "superseded_by": "sha256:old", + }], + }, + ) + + assert client.resolve_digest("sha256:old") == "sha256:old" + + +def test_resolve_digest_returns_original_for_no_matches() -> None: + client = ResolveDigestClient() + + assert client.resolve_digest("missing") == "missing" + + +def test_resolve_digest_returns_original_for_ambiguous_matches() -> None: + client = ResolveDigestClient(by_digest={ + "ambiguous": [ + {"digest": "sha256:one"}, + {"digest": "sha256:two"}, + ], + }) + + assert client.resolve_digest("ambiguous") == "ambiguous" + + +def test_resolve_digest_falls_back_to_name_substring() -> None: + client = ResolveDigestClient( + by_name={"component-name": [{"digest": "sha256:by-name", "deprecated": False}]}, + ) + + assert client.resolve_digest("component-name") == "sha256:by-name" + assert client.lookups == [ + { + "include_deprecated": True, + "name_substring": None, + "published_by_substring": None, + "digest": "component-name", + }, + { + "include_deprecated": True, + "name_substring": "component-name", + "published_by_substring": None, + "digest": None, + }, + ] + + +def test_make_request_retries_after_refresh_auth_on_401() -> None: + class RefreshingClient(TangleApiClient): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.refreshes = 0 + + def _refresh_auth(self) -> None: + self.refreshes += 1 + self.headers["Authorization"] = f"Bearer refreshed-{self.refreshes}" + + session = FakeSession([response({"error": "unauthorized"}, 401), response({"ok": True})]) + client = RefreshingClient("https://api.test", session=session) + + r = client._make_request("GET", "/api/users/me") + + assert r.status_code == 200 + assert client.refreshes == 2 + assert len(session.calls) == 2 + assert session.calls[-1]["headers"]["Authorization"] == "Bearer refreshed-2" diff --git a/tests/test_tangle_cli_cli.py b/tests/test_tangle_cli_cli.py new file mode 100644 index 0000000..d758bc7 --- /dev/null +++ b/tests/test_tangle_cli_cli.py @@ -0,0 +1,16 @@ +"""Tests for the tangle-cli root CLI.""" + +from __future__ import annotations + +import pytest + + +def test_tangle_cli_version_command_prints_package_version(capsys) -> None: + from tangle_cli import __version__ + from tangle_cli.cli import build_app + + with pytest.raises(SystemExit) as exc: + build_app()(tokens=["version"], exit_on_error=False) + + assert exc.value.code == 0 + assert capsys.readouterr().out.strip() == __version__ diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py new file mode 100644 index 0000000..69e4f8c --- /dev/null +++ b/tests/test_tangle_deploy_compat_imports.py @@ -0,0 +1,211 @@ +"""Import guards for the compatibility surface tangle-deploy consumes.""" + +from __future__ import annotations + +import importlib.util + + +def test_tangle_deploy_required_import_surface_includes_static_client() -> None: + import tangle_cli + from tangle_cli import TangleDynamicDiscoveryClient, utils as utils_module + from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient as DynamicDiscoveryClient + from tangle_cli.client import TangleApiClient as StaticClient + from tangle_cli.args_container import ArgsContainer, ConfigFileError + from tangle_cli.component_from_func import generate_component_yaml + from tangle_cli.component_generator import regenerate_yaml + from tangle_cli.component_publisher import ( + ComponentPublishContext, + ComponentPublisher, + ProcessingOutcome, + ProcessingResult, + deprecate_component, + deprecate_old_components, + perform_version_check, + prepare_component_for_publish, + publish_component, + publish_component_to_tangle, + ) + from tangle_cli.artifacts import ArtifactManager + from tangle_cli.module_bundler import ModuleBundler + from tangle_cli.secrets import SecretsManager + from tangle_cli.version_manager import bump_version + from tangle_cli.pipeline_run_details import PipelineRunDetails + from tangle_cli.pipeline_run_search import PipelineRunSearch + from tangle_cli.component_inspector import ComponentInspector + from tangle_cli.pipeline_dehydrator import ( + DehydrateChoice, + Jinja2ExportResult, + PipelineDehydrator, + ) + from tangle_cli.logger import ( + CaptureLogger, + CliLogType, + ConsoleLogger, + Logger, + NullLogger, + _null_logger, + _print_result, + get_default_logger, + run_with_logging, + ) + from tangle_cli.models import ( + ArtifactComponentQuery, + ArtifactInfo, + ComponentInfo, + ComponentSpec, + ContainerState, + DebugInfo, + GraphExecutionState, + KubernetesDebugInfo, + KubernetesJobInfo, + PageChunk, + PipelineRun, + RunDetails, + SecretInfo, + TaskSpec, + UserInfo, + ) + from tangle_cli.utils import ( + _CI_BRANCH_VARS, + _CI_GIT_ROOT_VARS, + _CI_REPO_URL_VARS, + _CI_SHA_VARS, + OrderedDict, + TaskProcessor, + _strip_text_from_graph, + add_official_prefix, + UnsetVarError, + _extract_recursive_params, + _extract_source_dir, + _fill_from_ci_env, + _literal_str_representer, + _LiteralBlockDumper, + _normalize_git_url, + _strip_internal_annotations, + apply_defaults, + check_versions, + clamp, + compare_versions, + compute_spec_digest, + compute_text_digest, + dump_yaml, + expand_vars, + find_documentation_path_for_yaml, + get_component_ref_info, + get_git_info, + get_git_root, + get_version_component, + get_version_from_data, + is_graph_task, + is_subgraph_spec, + normalize_annotation_paths, + parse_yaml_string, + resolve_input_path, + set_component_yaml_path, + tangle_verbose_enabled, + traverse_pipeline_tasks, + ) + + assert TangleDynamicDiscoveryClient.__name__ == DynamicDiscoveryClient.__name__ == "TangleDynamicDiscoveryClient" + assert StaticClient.__name__ == "TangleApiClient" + assert not hasattr(tangle_cli, "TangleApiClient") + assert importlib.util.find_spec("tangle_cli.client") is not None + assert callable(StaticClient("https://api.test").set_verbose) + assert ComponentSpec.__name__ == "ComponentSpec" + assert PipelineRun.__name__ == "PipelineRun" + assert ArgsContainer and ConfigFileError + assert callable(generate_component_yaml) + assert callable(regenerate_yaml) + assert ComponentPublisher is not None + assert ComponentPublishContext is not None + assert ProcessingOutcome.SUCCESS.value == "success" + assert ProcessingResult is not None + assert callable(perform_version_check) + assert callable(deprecate_old_components) + assert callable(prepare_component_for_publish) + assert callable(publish_component) + assert callable(publish_component_to_tangle) + assert callable(deprecate_component) + assert callable(bump_version) + assert ModuleBundler is not None + assert ArtifactManager is not None + assert callable(ArtifactManager.serialize_artifacts) + assert SecretsManager is not None + assert PipelineRunDetails is not None + assert PipelineRunSearch is not None + assert callable(SecretsManager.resolve_secret_value) + assert callable(ComponentInspector.get_standard_library) + assert callable(ComponentInspector.inspect_by_digest) + assert callable(ComponentInspector.inspect_by_name) + assert callable(ComponentInspector.search_components) + assert callable(ComponentInspector.transparency_check) + assert DehydrateChoice.AUTO == "a" + assert Jinja2ExportResult is not None + assert PipelineDehydrator is not None + assert callable(get_default_logger) + assert callable(run_with_logging) + assert ConsoleLogger and CaptureLogger and NullLogger and Logger and CliLogType + assert _null_logger is not None + assert callable(_print_result) + assert ArtifactComponentQuery and ArtifactInfo and ComponentInfo + assert ContainerState and DebugInfo and GraphExecutionState + assert KubernetesDebugInfo and KubernetesJobInfo and PageChunk and RunDetails + assert SecretInfo and TaskSpec and UserInfo + assert callable(_strip_text_from_graph) + assert add_official_prefix("demo") == "[Official] demo" + assert OrderedDict is not None and TaskProcessor is not None + assert UnsetVarError is not None and _LiteralBlockDumper is not None + assert callable(_extract_recursive_params) + assert callable(_extract_source_dir) + assert callable(_fill_from_ci_env) + assert callable(_literal_str_representer) + assert callable(_normalize_git_url) + assert callable(_strip_internal_annotations) + assert callable(apply_defaults) + assert callable(check_versions) + assert callable(clamp) + assert callable(compare_versions) + assert callable(compute_spec_digest) + assert callable(compute_text_digest) + assert callable(dump_yaml) + assert callable(expand_vars) + assert callable(find_documentation_path_for_yaml) + assert callable(get_component_ref_info) + assert callable(get_git_info) + assert callable(get_git_root) + assert callable(get_version_component) + assert callable(get_version_from_data) + assert callable(is_graph_task) + assert callable(is_subgraph_spec) + assert callable(normalize_annotation_paths) + assert callable(parse_yaml_string) + assert callable(resolve_input_path) + assert callable(set_component_yaml_path) + assert callable(tangle_verbose_enabled) + assert callable(traverse_pipeline_tasks) + assert _CI_GIT_ROOT_VARS and _CI_SHA_VARS and _CI_BRANCH_VARS and _CI_REPO_URL_VARS + assert utils_module is not None + + +def test_ci_var_globals_are_mutable_for_downstream_provider_overrides() -> None: + from tangle_cli import utils as u + + original_git_root = u._CI_GIT_ROOT_VARS + original_sha = u._CI_SHA_VARS + original_branch = u._CI_BRANCH_VARS + original_repo = u._CI_REPO_URL_VARS + try: + u._CI_GIT_ROOT_VARS = ("APPLICATION_ROOT", *u._CI_GIT_ROOT_VARS) + u._CI_SHA_VARS = ("PROVIDER_BUILD_COMMIT", *u._CI_SHA_VARS) + u._CI_BRANCH_VARS = ("PROVIDER_BUILD_BRANCH", *u._CI_BRANCH_VARS) + u._CI_REPO_URL_VARS = ("PROVIDER_BUILD_REPO", *u._CI_REPO_URL_VARS) + + assert u._CI_GIT_ROOT_VARS[0] == "APPLICATION_ROOT" + assert u._CI_SHA_VARS[0] == "PROVIDER_BUILD_COMMIT" + assert u._CI_BRANCH_VARS[0] == "PROVIDER_BUILD_BRANCH" + assert u._CI_REPO_URL_VARS[0] == "PROVIDER_BUILD_REPO" + finally: + u._CI_GIT_ROOT_VARS = original_git_root + u._CI_SHA_VARS = original_sha + u._CI_BRANCH_VARS = original_branch + u._CI_REPO_URL_VARS = original_repo diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..24f6a4c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,179 @@ +"""Tests for a representative slice of :mod:`tangle_cli.utils`. + +The utils module is large; this file covers the helpers that are most +likely to break silently across version bumps or refactors: +version parsing/comparison, YAML round-trip, digest stability, and +env-var-driven configuration toggles. +""" + +from __future__ import annotations + +import pytest + +from tangle_cli.utils import ( + UnsetVarError, + _normalize_git_url, + apply_defaults, + check_versions, + clamp, + compare_versions, + compute_spec_digest, + compute_text_digest, + dump_yaml, + expand_vars, + get_version_from_data, + parse_yaml_string, + set_component_yaml_path, + tangle_verbose_enabled, +) + + +class TestClamp: + def test_within_bounds(self): + assert clamp(5, 0, 10) == 5 + + def test_lower_bound(self): + assert clamp(-1, 0, 10) == 0 + + def test_upper_bound(self): + assert clamp(11, 0, 10) == 10 + + +class TestTangleVerboseEnabled: + @pytest.mark.parametrize("value,expected", [ + ("1", True), ("true", True), ("True", True), ("yes", True), + ("0", False), ("false", False), ("", False), ("anything-else", False), + ]) + def test_env_var_truthiness(self, value, expected, monkeypatch): + monkeypatch.setenv("TANGLE_VERBOSE", value) + assert tangle_verbose_enabled() is expected + + def test_unset(self, monkeypatch): + monkeypatch.delenv("TANGLE_VERBOSE", raising=False) + assert tangle_verbose_enabled() is False + + +class TestExpandVars: + def test_basic_substitution(self): + assert expand_vars("hello ${name}", {"name": "world"}) == "hello world" + + def test_default_value(self): + assert expand_vars("hello ${name:-friend}", {}) == "hello friend" + + def test_default_ignored_when_set(self): + assert expand_vars("hello ${name:-friend}", {"name": "alice"}) == "hello alice" + + def test_unset_without_default_raises(self): + with pytest.raises(UnsetVarError): + expand_vars("hello ${name}", {}) + + +class TestVersionHelpers: + def test_get_version_from_data_in_annotations(self): + data = {"metadata": {"annotations": {"version": "1.2.3"}}} + assert get_version_from_data(data) == "1.2.3" + + def test_get_version_from_data_top_level_fallback(self): + # Top-level ``version`` field also accepted. + data = {"version": "0.1"} + assert get_version_from_data(data) == "0.1" + + def test_get_version_from_data_missing(self): + # Returns None when no version annotation is set. + assert get_version_from_data({}) is None + + def test_compare_versions(self): + assert compare_versions("1.2.0", "1.2.0") == 0 + assert compare_versions("1.2.0", "1.2.1") < 0 + assert compare_versions("2.0.0", "1.9.9") > 0 + # Short vs. long forms compare component-wise. + assert compare_versions("1.2", "1.2.0") == 0 + + def test_check_versions_equal_returns_false(self): + # ``check_versions`` returns ``True`` when an update should proceed. + # Equal versions => no update needed. + assert check_versions("1.0", "1.0") is False + + def test_check_versions_different_returns_true(self): + assert check_versions("1.0", "1.1") is True + + def test_check_versions_no_latest_proceeds(self): + # No latest version published yet => first publish proceeds. + assert check_versions("1.0", None) is True + + +class TestYamlRoundtrip: + def test_parse_dump_preserves_keys(self): + text = "a: 1\nb:\n c: 2\n" + data = parse_yaml_string(text) + assert data == {"a": 1, "b": {"c": 2}} + # dump_yaml should preserve insertion order for a plain dict. + dumped = dump_yaml(data) + round_tripped = parse_yaml_string(dumped) + assert round_tripped == data + + def test_multiline_string_uses_literal_block(self): + # The custom dumper renders multiline strings with the ``|`` block + # scalar so they read nicely in component YAML files. + data = {"description": "line one\nline two\n"} + dumped = dump_yaml(data) + assert "|" in dumped + assert "line one" in dumped and "line two" in dumped + + +class TestDigest: + def test_text_digest_stable_and_unique(self): + d1 = compute_text_digest("hello") + d2 = compute_text_digest("hello") + d3 = compute_text_digest("hello!") + assert d1 == d2 + assert d1 != d3 + # Reasonable shape — non-empty string, deterministic. + assert isinstance(d1, str) and d1 + + def test_spec_digest_independent_of_key_order(self): + a = {"name": "c", "version": "1.0", "inputs": []} + b = {"inputs": [], "version": "1.0", "name": "c"} + assert compute_spec_digest(a) == compute_spec_digest(b) + + +class TestApplyDefaults: + def test_entry_values_take_precedence(self): + # ``apply_defaults`` returns a merged dict; entry values win on collision. + result = apply_defaults({"a": 1}, {"a": 99, "b": 2, "c": 3}) + assert result == {"a": 1, "b": 2, "c": 3} + + def test_list_of_dicts(self): + result = apply_defaults( + [{"a": 1}, {"a": 2, "b": "keep"}], + {"a": 99, "b": "default"}, + ) + assert result == [{"a": 1, "b": "default"}, {"a": 2, "b": "keep"}] + + +class TestSetComponentYamlPath: + def test_splits_relative_path(self): + ann: dict[str, str] = {} + set_component_yaml_path("a/b/comp.yaml", ann) + assert ann == {"git_relative_dir": "a/b", "component_yaml_path": "comp.yaml"} + + def test_bare_filename(self): + ann: dict[str, str] = {} + set_component_yaml_path("comp.yaml", ann) + assert ann == {"component_yaml_path": "comp.yaml"} + + def test_no_overwrite_mode(self): + ann = {"component_yaml_path": "old.yaml"} + set_component_yaml_path("new.yaml", ann, overwrite=False) + assert ann["component_yaml_path"] == "old.yaml" + + +class TestNormalizeGitUrl: + @pytest.mark.parametrize("input_url,expected", [ + ("git@github.com:Org/repo.git", "https://github.com/Org/repo"), + ("https://github.com/Org/repo.git", "https://github.com/Org/repo"), + ("https://github.com/Org/repo", "https://github.com/Org/repo"), + ("ssh://git@github.com/Org/repo.git", "https://github.com/Org/repo"), + ]) + def test_normalization(self, input_url, expected): + assert _normalize_git_url(input_url) == expected diff --git a/tests/test_version_manager.py b/tests/test_version_manager.py new file mode 100644 index 0000000..404d42f --- /dev/null +++ b/tests/test_version_manager.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +"""Tests for the version manager.""" + +import tempfile +from pathlib import Path + +import yaml + +from tangle_cli.component_from_func import generate_component_yaml +from tangle_cli.version_manager import bump_version + + +def test_bump_plain_yaml_no_prior_version(): + """Test bump_version on a YAML file with no version field.""" + yaml_content = '''name: test-component +metadata: + annotations: + description: A test component +''' + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + temp_path = Path(f.name) + + try: + result = bump_version(temp_path) + + assert result["status"] == "success" + + with open(temp_path) as f: + data = yaml.safe_load(f) + assert data["metadata"]["annotations"]["version"] == "0.1" + finally: + temp_path.unlink() + + +def test_bump_plain_yaml_major_minor(): + """Test bump_version on a plain YAML file with major.minor version.""" + yaml_content = '''name: test-component +version: "2.3" +metadata: + annotations: + description: A test component +spec: + inputs: [] +''' + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + temp_path = Path(f.name) + + try: + result = bump_version(temp_path) + + assert result["status"] == "success" + + with open(temp_path) as f: + data = yaml.safe_load(f) + # Version migrated to metadata.annotations, top-level removed + assert "version" not in data + assert data["metadata"]["annotations"]["version"] == "2.4" + finally: + temp_path.unlink() + + +def test_bump_plain_yaml_major_minor_patch(): + """Test bump_version on a plain YAML file with major.minor.patch version.""" + yaml_content = '''name: test-component +version: "1.2.3" +metadata: + annotations: + description: A test component +''' + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + temp_path = Path(f.name) + + try: + result = bump_version(temp_path) + + assert result["status"] == "success" + + with open(temp_path) as f: + data = yaml.safe_load(f) + # Version migrated to metadata.annotations, top-level removed + assert "version" not in data + assert data["metadata"]["annotations"]["version"] == "1.2.4" + finally: + temp_path.unlink() + + +def test_bump_plain_yaml_with_timestamp(): + """Test bump_version with timestamp update.""" + yaml_content = '''name: test-component +version: "1.5" +updated_at: "2024-01-01T00:00:00Z" +''' + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + temp_path = Path(f.name) + + try: + result = bump_version(temp_path, update_timestamp=True) + + assert result["status"] == "success" + + with open(temp_path) as f: + data = yaml.safe_load(f) + # Version and timestamp migrated to metadata.annotations, top-level removed + assert "version" not in data + assert "updated_at" not in data + assert data["metadata"]["annotations"]["version"] == "1.6" + assert data["metadata"]["annotations"]["updated_at"] != "2024-01-01T00:00:00Z" + assert "T" in data["metadata"]["annotations"]["updated_at"] # ISO format + finally: + temp_path.unlink() + + +def test_bump_yaml_with_python_source(): + """Test bump_version when YAML has a Python source file.""" + python_content = '''"""Test component.""" + + +def test_component(input_value: str) -> str: + """A test component function. + + Metadata: + version: 1.5 + updated_at: 2024-01-01T00:00:00Z + + Args: + input_value: The input value. + + Returns: + The output value. + """ + return input_value +''' + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + sources_dir = temp_path / "sources" + sources_dir.mkdir() + + # Create Python source file + python_file = sources_dir / "test_component.py" + python_file.write_text(python_content) + + # Create YAML file pointing to Python source + yaml_content = f'''name: test-component +version: "1.5" +updated_at: "2024-01-01T00:00:00Z" +metadata: + annotations: + python_original_code_path: {python_file.name} +implementation: + container: + image: us-docker.pkg.dev/test/image:latest +''' + yaml_file = temp_path / "test-component.yaml" + yaml_file.write_text(yaml_content) + + # Bump version with timestamp + result = bump_version(yaml_file, update_timestamp=True) + + assert result["status"] == "success" + + # Verify Python file was updated + updated_python = python_file.read_text() + assert "version: 1.6" in updated_python + assert "2024-01-01T00:00:00Z" not in updated_python + + # Verify YAML was regenerated with new version + with open(yaml_file) as f: + data = yaml.safe_load(f) + assert data["metadata"]["annotations"]["version"] == "1.6" + + +def test_bump_yaml_with_python_source_in_separate_output_dir(tmp_path: Path): + """Resolve source paths relative to component_yaml_path/common root.""" + + src_dir = tmp_path / "src" + out_dir = tmp_path / "generated" + src_dir.mkdir() + out_dir.mkdir() + python_file = src_dir / "test_component.py" + python_file.write_text('''"""Test component.""" + + +def test_component(input_value: str) -> str: + """A test component function. + + Metadata: + version: 1.5 + + Args: + input_value: The input value. + + Returns: + The output value. + """ + return input_value +''') + yaml_file = out_dir / "test-component.yaml" + yaml_file.write_text('''name: test-component +metadata: + annotations: + version: '1.5' + python_original_code_path: src/test_component.py + component_yaml_path: generated/test-component.yaml +implementation: + container: + image: python:3.12 +''') + + result = bump_version(yaml_file) + + assert result["status"] == "success" + assert result["old_version"] == "1.5" + assert result["new_version"] == "1.6" + assert "version: 1.6" in python_file.read_text() + data = yaml.safe_load(yaml_file.read_text()) + assert data["metadata"]["annotations"]["version"] == "1.6" + assert data["metadata"]["annotations"]["python_original_code_path"] == "src/test_component.py" + assert data["metadata"]["annotations"]["component_yaml_path"] == "generated/test-component.yaml" + + +def test_bump_yaml_with_missing_python_source_fails_without_yaml_fallback(tmp_path: Path): + """Do not rewrite embedded python_original_code when annotated source is missing.""" + + yaml_file = tmp_path / "test-component.yaml" + original = '''name: test-component +metadata: + annotations: + version: '1.5' + python_original_code_path: missing/test_component.py + python_original_code: | + def test_component(input_value: str) -> str: + """A test component function. + + Metadata: + version: 9.9 + """ + return input_value +implementation: + container: + image: python:3.12 +''' + yaml_file.write_text(original) + + result = bump_version(yaml_file) + + assert result["status"] == "failed" + assert "Python source not found" in str(result["error"]) + assert yaml_file.read_text() == original + + +def test_bump_generated_yaml_preserves_selected_function(tmp_path: Path): + python_file = tmp_path / "multi.py" + yaml_file = tmp_path / "out.yaml" + python_file.write_text('''def helper(value: str) -> str: + """Helper function. + + Metadata: + name: Helper + version: 8.0 + + Args: + value: Value. + + Returns: + Value. + """ + return value + + +def target(value: str) -> str: + """Target function. + + Metadata: + name: Target + version: 1.0 + + Args: + value: Value. + + Returns: + Value. + """ + return helper(value) +''') + assert generate_component_yaml( + python_file, + yaml_file, + container_image="python:3.12", + function_name="target", + ) + + result = bump_version(yaml_file) + + assert result["status"] == "success" + assert result["old_version"] == "1.0" + assert result["new_version"] == "1.1" + updated_source = python_file.read_text() + assert "name: Helper\n version: 8.0" in updated_source + assert "def target" in updated_source + assert "version: 1.1" in updated_source + data = yaml.safe_load(yaml_file.read_text()) + assert data["name"] == "Target" + assert data["metadata"]["annotations"]["version"] == "1.1" + assert data["metadata"]["annotations"]["tangle_cli_generation_function_name"] == "target" + + +def test_bump_generated_yaml_fails_when_persisted_function_is_missing(tmp_path: Path): + python_file = tmp_path / "multi.py" + yaml_file = tmp_path / "out.yaml" + initial_source = '''def helper(value: str) -> str: + """Helper function. + + Metadata: + name: Helper + version: 8.0 + + Args: + value: Value. + + Returns: + Value. + """ + return value + + +def target(value: str) -> str: + """Target function. + + Metadata: + name: Target + version: 1.0 + + Args: + value: Value. + + Returns: + Value. + """ + return helper(value) +''' + python_file.write_text(initial_source) + assert generate_component_yaml( + python_file, + yaml_file, + container_image="python:3.12", + function_name="target", + ) + generated_yaml = yaml_file.read_text() + + source_without_target = '''def helper(value: str) -> str: + """Helper function. + + Metadata: + name: Helper + version: 8.0 + + Args: + value: Value. + + Returns: + Value. + """ + return value +''' + python_file.write_text(source_without_target) + + result = bump_version(yaml_file) + + assert result["status"] == "failed" + assert python_file.read_text() == source_without_target + assert yaml_file.read_text() == generated_yaml + + +def test_bump_generated_yaml_preserves_custom_name(tmp_path: Path): + python_file = tmp_path / "component.py" + yaml_file = tmp_path / "component.yaml" + python_file.write_text('''def component(value: str) -> str: + """Component function. + + Metadata: + name: Inferred Name + version: 1.0 + + Args: + value: Value. + + Returns: + Value. + """ + return value +''') + assert generate_component_yaml( + python_file, + yaml_file, + container_image="python:3.12", + custom_name="Custom Name", + ) + + result = bump_version(yaml_file) + + assert result["status"] == "success" + assert result["new_version"] == "1.1" + data = yaml.safe_load(yaml_file.read_text()) + assert data["name"] == "Custom Name" + assert data["metadata"]["annotations"]["version"] == "1.1" + + +def test_bump_generated_yaml_preserves_bundle_mode(tmp_path: Path): + helpers_dir = tmp_path / "helpers" + helpers_dir.mkdir() + (helpers_dir / "__init__.py").write_text("", encoding="utf-8") + (helpers_dir / "utils.py").write_text("def clean(text):\n return text.strip().lower()\n", encoding="utf-8") + python_file = tmp_path / "component.py" + yaml_file = tmp_path / "component.yaml" + python_file.write_text('''from helpers.utils import clean + + +def component(value: str) -> str: + """Component function. + + Metadata: + version: 1.0 + + Args: + value: Value. + + Returns: + Value. + """ + return clean(value) +''') + assert generate_component_yaml( + python_file, + yaml_file, + container_image="python:3.12", + function_name="component", + mode="bundle", + ) + + result = bump_version(yaml_file) + + assert result["status"] == "success" + assert result["new_version"] == "1.1" + data = yaml.safe_load(yaml_file.read_text()) + annotations = data["metadata"]["annotations"] + command = data["implementation"]["container"]["command"][-1] + assert annotations["tangle_cli_generation_mode"] == "bundle" + assert "helpers.utils" in annotations["bundled_modules"] + assert "_EMBEDDED_MODULES" in command + assert "helpers.utils" in command + + +def test_bump_yaml_with_python_source_no_prior_version(): + """Test bump_version when YAML has Python source with no prior version.""" + python_content = '''"""Test component.""" + + +def test_component(input_value: str) -> str: + """A test component function. + + Metadata: + + Args: + input_value: The input value. + + Returns: + The output value. + """ + return input_value +''' + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + sources_dir = temp_path / "sources" + sources_dir.mkdir() + + # Create Python source file + python_file = sources_dir / "test_component.py" + python_file.write_text(python_content) + + # Create YAML file pointing to Python source (no version) + yaml_content = f'''name: test-component +metadata: + annotations: + python_original_code_path: {python_file.name} +implementation: + container: + image: us-docker.pkg.dev/test/image:latest +''' + yaml_file = temp_path / "test-component.yaml" + yaml_file.write_text(yaml_content) + + # Bump version + result = bump_version(yaml_file) + + assert result["status"] == "success" + + # Verify Python file was updated with initial version + updated_python = python_file.read_text() + assert "version: 0.1" in updated_python + + # Verify YAML was regenerated with new version + with open(yaml_file) as f: + data = yaml.safe_load(f) + assert data["metadata"]["annotations"]["version"] == "0.1" diff --git a/third_party/tangle b/third_party/tangle new file mode 160000 index 0000000..479f3e9 --- /dev/null +++ b/third_party/tangle @@ -0,0 +1 @@ +Subproject commit 479f3e92d26fc1a5ad4be9f879a5179f2c090f32 diff --git a/uv.lock b/uv.lock index 01d87df..10bad91 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,31 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[options] +exclude-newer = "2026-06-19T17:04:09.933107Z" +exclude-newer-span = "P7D" + +[manifest] +members = [ + "tangle-api", + "tangle-cli", +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -60,130 +85,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "bugsnag" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/57/624bf8c27aea8ed3373cad9c1be84ad1f6864ee7cc9c917765f5053aef15/bugsnag-4.9.0.tar.gz", hash = "sha256:c22e2d1f0148282a0898e4c81a0ed39c65ee566e20bdc43cec04b978eb272b2b", size = 73590, upload-time = "2026-04-21T14:33:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/ac/0b3febd6fbf810312166ae006c580341bd66f951b57127b21f701ef58977/bugsnag-4.9.0-py3-none-any.whl", hash = "sha256:1566a914fccb86a90e64f5214f37a2d9997654e5b1164f86ab3dec76b7f00634", size = 46226, upload-time = "2026-04-21T14:33:53.433Z" }, +] + [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -229,7 +266,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.19.0" +version = "4.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -239,9 +276,18 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/e7/4b3048094559b86a800e0f0de7faf8e6d8213727cf31553ec58453f25abc/cyclopts-4.19.0.tar.gz", hash = "sha256:c7532803ab8560d4de8600769793c3de4b2dc8c3b23ec707b989d84d9bae6ff4", size = 189274, upload-time = "2026-06-22T23:58:54.176Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/07/bf61d13de86d96a4c46aff00c9ca0eced44bcc8c3e16280605c1253e5720/cyclopts-4.16.1.tar.gz", hash = "sha256:8aa47bf92a5fb33abca5af05e576eecdb0d2f79893ad29238046df78370fc4a8", size = 181196, upload-time = "2026-05-25T15:29:08.518Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/e8/b84c32f35a19107b55b8ceed260de9469206464da26bd0b979db470782c3/cyclopts-4.19.0-py3-none-any.whl", hash = "sha256:a8c11adb45afae25db310122950c7639c70b56e2ce341e5b29848bf3a8ecc1d4", size = 228005, upload-time = "2026-06-22T23:58:52.495Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/7f362c2fb8ef4decd2160bc24d4292c6ca658cc6d9a161b89ca5122bbdbf/cyclopts-4.16.1-py3-none-any.whl", hash = "sha256:617795392c4113a2c2cc7af716f20244900e87f23daa05442d1268d81472a592", size = 219020, upload-time = "2026-05-25T15:29:09.646Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, ] [[package]] @@ -269,11 +315,11 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] @@ -312,7 +358,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.2" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -321,15 +367,16 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [package.optional-dependencies] standard = [ { name = "email-validator" }, { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, { name = "httpx" }, { name = "jinja2" }, { name = "pydantic-extra-types" }, @@ -361,9 +408,10 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.15.1" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "detect-installer" }, { name = "fastar" }, { name = "httpx" }, { name = "pydantic", extra = ["email"] }, @@ -373,252 +421,268 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/f2/fcd66ce245b7e3c3d84ca8717eda8896945fbc17c87a9b03f490ff06ace7/fastapi_cloud_cli-0.15.1.tar.gz", hash = "sha256:71a46f8a1d9fea295544113d6b79f620dc5768b24012887887306d151165745d", size = 43851, upload-time = "2026-03-26T10:23:12.932Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/7c/f194925af8fabdb0b7a886a1b89087c0b7f327f99e79497a882aa94c1e34/fastapi_cloud_cli-0.19.0.tar.gz", hash = "sha256:f97b31c2ad6af3832eb4065870bdca3365b6e827a0ccf6eeb15e477bc1662b13", size = 57476, upload-time = "2026-06-01T08:24:03.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/11/ecb0d5e1d114e8aaec1cdc8ee2d7b0f54292585067effe2756bde7e7a4b0/fastapi_cloud_cli-0.15.1-py3-none-any.whl", hash = "sha256:b1e8b3b26dc314e180fc0ab67dfd39d7d9fe160d3951081d09184eafaacf5649", size = 32284, upload-time = "2026-03-26T10:23:14.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/1a2ec890fc273b9da2b173ca45f692a2e24a369bdd39ea7812c1d8a799e5/fastapi_cloud_cli-0.19.0-py3-none-any.whl", hash = "sha256:a2dfc4074c321e63ec88589cc1f90573d4b5bf980ddc44a7033e6f3cd8e96628", size = 38239, upload-time = "2026-06-01T08:24:02.437Z" }, ] [[package]] name = "fastar" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/00/dab9ca274cf1fde19223fea7104631bea254751026e75bf99f2b6d0d1568/fastar-0.9.0.tar.gz", hash = "sha256:d49114d5f0b76c5cc242875d90fa4706de45e0456ddedf416608ecd0787fb410", size = 70124, upload-time = "2026-03-20T14:26:34.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/48/3d8e24c9ae7796e59231f50133640463c6a20b00ce684b308dc6de0e28fe/fastar-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:19a384395f26daa3ecb6c24054f3a50ce919e250e06b82614a252a0fadcbca17", size = 709092, upload-time = "2026-03-20T14:25:30.007Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/4d7dc06f3ad5457b9a1510a75e3f9ec431ad020688fcf954012a2bcae6e8/fastar-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b9c82b1fef26d8fd4abad1152f4c74eeb86bc9d46c814757b695847a751b9b0b", size = 630252, upload-time = "2026-03-20T14:25:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/79/d4/ebb285a263cc2070d04d39917288b5d1c7f49e1c47ed5544e86283e091c6/fastar-0.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e5c91cb4527a6e634e7477a01aa52ccfbb978df1d9803172685c1e0802a2c18c", size = 869584, upload-time = "2026-03-20T14:24:52.067Z" }, - { url = "https://files.pythonhosted.org/packages/23/19/a293b6f75ea1b9e14d384859253ee65f966a73be306cea39552a557c9e34/fastar-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6bc32f40a3e8ab12b8ebce48c4808d2bcf89bd3dac3023980b8a9b4aaf719f2", size = 762379, upload-time = "2026-03-20T14:23:47.429Z" }, - { url = "https://files.pythonhosted.org/packages/95/2f/a31f00c31f16a3bffd6f6ab3414964100fb35a79983f21283fc8b81d3cec/fastar-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee4a1d85a58cd955a5409b221450762b851879ce6e080d6d717265fb9a4e939d", size = 759567, upload-time = "2026-03-20T14:24:00.677Z" }, - { url = "https://files.pythonhosted.org/packages/b0/46/5a4b1fb1e5c8b6cd1eb464e658ed75d667f1f53834f353e6323ca71bd113/fastar-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72e25ec1cbad0fc2a5f93a147978cc41e054ce5789807ebd3bcece5f276c0c2", size = 925850, upload-time = "2026-03-20T14:24:13.669Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/a5543fb1b059a82ce4c6fc571fe429390294e8150c09bb537d228471eac6/fastar-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862ddfaf73c7388d708bcbeb75e2e336605465b88d952407621c847bab5d3cb", size = 818858, upload-time = "2026-03-20T14:24:39.431Z" }, - { url = "https://files.pythonhosted.org/packages/53/9a/af5ae6d24e1170702d096225989b4ee3470b22bbecb5c09c899e816aefd7/fastar-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3471fa2627b9703830d13c8b0a6ba19eeff4e2e0ff924631065ecceca56abb2b", size = 821941, upload-time = "2026-03-20T14:25:05.534Z" }, - { url = "https://files.pythonhosted.org/packages/54/3f/399d8b080f7c5fe1fa88dadaa7a30bd0bb885ad490d3c2ef2c667877c5c4/fastar-0.9.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:e3d2e68e0239ab24b65b0674f2b74ac71d8fb5ea221a3e0d0ab966292bd83e12", size = 886548, upload-time = "2026-03-20T14:24:26.209Z" }, - { url = "https://files.pythonhosted.org/packages/63/cd/034b5f61e99df67e092e1d3d538150a5f562d00c0259e6402cbcb62e15a9/fastar-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dcabfe31c48ff6a994c3dc4ddc27287b15d78a09c737beef8a6b1f210b720a6a", size = 970244, upload-time = "2026-03-20T14:25:42.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8f/3a8b0d711050b300a3448c9d145c6d234958e148e456ab4a15daca6e4b05/fastar-0.9.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f1723bb9cc3dcd087b5dd066a0369f27529a925d467ccc896d1f6cd0212417bf", size = 1036944, upload-time = "2026-03-20T14:25:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/34/11/cd5ebd16529c5fbff2431b494bd6f3f8ecafeca8f874449bf65ccf58c77b/fastar-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3ec2e699af02ba78f359b1cf1f4b3da22f41dec3a327f1cda6a1d31a43365a71", size = 1078612, upload-time = "2026-03-20T14:26:09.042Z" }, - { url = "https://files.pythonhosted.org/packages/85/c7/752c184e3c5e8de592e5d7ce3d081bf665ae5dbbe4a3df816daf38043143/fastar-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:761708eb2f6e402d4cda04ac81d0c2406b1c10375601e238083d2e885ec52a42", size = 1029368, upload-time = "2026-03-20T14:26:21.79Z" }, - { url = "https://files.pythonhosted.org/packages/68/b3/2a5551942adaecb9874ebc0d0922f3ab9dd058298b7a36a7900da93a3e68/fastar-0.9.0-cp310-cp310-win32.whl", hash = "sha256:a5ea0969c94845faed7bf681850df704da9617ad7231850dbc7ca4017080133a", size = 454507, upload-time = "2026-03-20T14:26:54.124Z" }, - { url = "https://files.pythonhosted.org/packages/23/30/7a2f25837ee7353ff5eaa815d9a6321f8704fcc39a94570a1b2d958639c0/fastar-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:e5646f10a747282904f2def929612ed19cace4bd702029c3d7c78205ef604abd", size = 486500, upload-time = "2026-03-20T14:26:42.142Z" }, - { url = "https://files.pythonhosted.org/packages/6f/01/4ecbe0b4938608f9c6c5c4d4f6b872975fe30152bfaa8e44fe0e3b6cbcc4/fastar-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:facc7522bd1c1e7569bedb602932fc7292408a320f415d72180634d58f661bf0", size = 708809, upload-time = "2026-03-20T14:25:31.299Z" }, - { url = "https://files.pythonhosted.org/packages/11/6a/085b3cae0e04da4d42306dc07e2cc4f95d9c8f27df4dfd1a25d0f80516cb/fastar-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8ac3e8aaee57dfc822b04f570f0a963c2381a9dc8990fe0c6e965efd23fd451", size = 629764, upload-time = "2026-03-20T14:25:19.017Z" }, - { url = "https://files.pythonhosted.org/packages/3c/c2/cdd996a37837e6cc5edc4d09775d2a2bc63e9e931129db69947cf4c77148/fastar-0.9.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d90493b4bb56db728b38eb18a551df386113d72ad4e7f1a97572f3662a9b8a85", size = 869631, upload-time = "2026-03-20T14:24:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/30/d4/4a5a3c341d26197ea3ae6bed79fc9bb4ead8ddc74a93bdb74e4ee0bac18e/fastar-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17e2c3b46408193ea13c1e1177275ca7951e88bd3dce16baccb8de4f5e0dc2e8", size = 762096, upload-time = "2026-03-20T14:23:49.175Z" }, - { url = "https://files.pythonhosted.org/packages/bc/dd/1d346cdfcd3064f6c435eff90a8d7cf0021487e3681453bdd681b9488d81/fastar-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:52f96a3d4cfbe4f06b376706fa0562f3a1d2329bc37168119af0e47e1ac21cab", size = 759627, upload-time = "2026-03-20T14:24:01.984Z" }, - { url = "https://files.pythonhosted.org/packages/02/a1/e91eb7ae1e41c0d3ead86dc199beb13a0b80101e2948d66adeb578b09e60/fastar-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57e9b94e485713c79bb259f7ecff1213527d05e9aa43a157c3fbc88812cf163e", size = 926211, upload-time = "2026-03-20T14:24:15.218Z" }, - { url = "https://files.pythonhosted.org/packages/9b/63/9fea9604e7aecc2f062f0df5729f74712d81615a1b18fa6a1a13106184fa/fastar-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb06d0a0cc3cf52a9c07559bb16ab99eb75afe0b3d5ce68f5c299569460851ac", size = 818748, upload-time = "2026-03-20T14:24:40.765Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f8/521438041d69873bb68b144b09080ae4f1621cebb8238b1e54821057206b/fastar-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c75e779f72d845037d4bf6692d01ac66f014eaef965c9231d41d5cc1276b89fc", size = 822380, upload-time = "2026-03-20T14:25:06.825Z" }, - { url = "https://files.pythonhosted.org/packages/92/05/f33cc3f5f96ffb7d81a7f06c9239d4eea584527292a030a73d3218148f41/fastar-0.9.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:24b13fc4ef3f1e3c9cc2dcf07ad9445900db9d3ce09b73021547a55994d0407f", size = 886569, upload-time = "2026-03-20T14:24:27.567Z" }, - { url = "https://files.pythonhosted.org/packages/60/32/6e7cb45dce544f97b0199325084a0a5a895cb903e0539690619e78d8d7cf/fastar-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec7852de506d022ad36ad56f4aefb10c259dd59e485bf87af827954d404ba9d5", size = 969993, upload-time = "2026-03-20T14:25:44.222Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ee/04cf9374e5e6a82ddc87073d684c1fa7a9ca368bf85c2786535b1bfc38a9/fastar-0.9.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a79c53c3003958dca88a7ec3dd805bf9c2fb2a659110039f44571d57e329e3d4", size = 1036738, upload-time = "2026-03-20T14:25:57.551Z" }, - { url = "https://files.pythonhosted.org/packages/b6/94/e6f6ad29c25c5f531a406e3a35ef5c034ea177748f9fb621073519adb3d5/fastar-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:00328ce7ae76be7f9e2faa6a221a0b41212e4115c27e2ac5e585bcf226bfc2eb", size = 1078557, upload-time = "2026-03-20T14:26:10.358Z" }, - { url = "https://files.pythonhosted.org/packages/1f/44/a1c9f6afe93d1cc1abb68a7cda2bada509d756d24e22d5d949ca86b4f45e/fastar-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c03fad1ad9ac57cf03a4db9e18c7109c37416ff4eb9ebfca98fcd2b233a26c4", size = 1029251, upload-time = "2026-03-20T14:26:23.215Z" }, - { url = "https://files.pythonhosted.org/packages/75/31/9e77bc2af3c8b8a433b7175d14b9c75d0ab901542c7452fdf942ece5a155/fastar-0.9.0-cp311-cp311-win32.whl", hash = "sha256:163ba4c543d2112c8186be2f134d11456b593071ba9ea3faba4f155bde7c5dac", size = 454633, upload-time = "2026-03-20T14:26:55.344Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d4/a78d51d1290cdce2d6d3162a18d12c736b71d3feef5a446b3fe021443eb3/fastar-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:2137d5d26044b44bb19197a8fc959256c772615ee959cddd0f74320b548fc966", size = 486772, upload-time = "2026-03-20T14:26:43.569Z" }, - { url = "https://files.pythonhosted.org/packages/fa/39/471aefca4c8180689cc0dc6f2f23bc283a3ca07114f713307fb947d320af/fastar-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:ecb94de3bc96d9fae95641a7907385541517a4c17416153d3b952d37dce0a2a3", size = 463586, upload-time = "2026-03-20T14:26:35.483Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9b/300bc0dafa8495718976076db216f42d57b251a582589566a63b4ed2cb82/fastar-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7a8b5daa50d9b4c07367dffc40880467170bf1c31ca63a2286506edbe6d3d65b", size = 706914, upload-time = "2026-03-20T14:25:32.501Z" }, - { url = "https://files.pythonhosted.org/packages/95/97/f1e34c8224dc373c6fab5b33e33be0d184751fdc27013af3278b1e4e6e6c/fastar-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ec841a69fea73361c6df6d9183915c09e9ce3bd96493763fa46019e79918400", size = 627422, upload-time = "2026-03-20T14:25:20.318Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ad/e2499d136e24c2d896f2ec58183c91c6f8185d758177537724ed2f3e1b54/fastar-0.9.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ad46bc23040142e9be4b4005ea366834dbf0f1b6a90b8ecdc3ec96c42dec4adf", size = 865265, upload-time = "2026-03-20T14:24:55.418Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/b6ad68b2ab1d7b74b0d38725d817418016bdd64880b36108be80d2460b4d/fastar-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de264da9e8ef6407aa0b23c7c47ed4e34fde867e7c1f6e3cb98945a93e5f89f2", size = 760583, upload-time = "2026-03-20T14:23:50.447Z" }, - { url = "https://files.pythonhosted.org/packages/b8/96/086116ad46e3b98f6c217919d680e619f2857ffa6b5cc0d7e46e4f214b83/fastar-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75c70be3a7da3ff9342f64c15ec3749c13ef56bc28e69075d82d03768532a8d0", size = 758000, upload-time = "2026-03-20T14:24:03.471Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e6/ea642ea61eea98d609343080399a296a9ff132bd0492a6638d6e0d9e41a7/fastar-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a734506b071d2a8844771fe735fbd6d67dd0eec80eef5f189bbe763ebe7a0b8", size = 923647, upload-time = "2026-03-20T14:24:16.875Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/53874aad61e4a664af555a2aa7a52fe46cfadd423db0e592fa0cfe0fa668/fastar-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eac084ab215aaf65fa406c9b9da1ac4e697c3d3a1a183e09c488e555802f62d", size = 816528, upload-time = "2026-03-20T14:24:42.048Z" }, - { url = "https://files.pythonhosted.org/packages/41/df/d663214d35380b07a24a796c48d7d7d4dc3a28ec0756edbcb7e2a81dc572/fastar-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb62e2369834fb23d26327157f0a2dbec40b230c709fa85b1ce96cf010e6fbf", size = 819050, upload-time = "2026-03-20T14:25:08.352Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5a/455b53f11527568100ba6d5847635430645bad62d676f0bae4173fc85c90/fastar-0.9.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f2f399fffb74bcd9e9d4507e253ace2430b5ccf61000596bda41e90414bcf4f2", size = 885257, upload-time = "2026-03-20T14:24:28.86Z" }, - { url = "https://files.pythonhosted.org/packages/4f/dd/0a8ea7b910293b07f8c82ef4e6451262ccf2a6f2020e880f184dc4abd6c2/fastar-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87006c8770dfc558aefe927590bbcdaf9648ca4472a9ee6d10dfb7c0bda4ce5b", size = 968135, upload-time = "2026-03-20T14:25:45.614Z" }, - { url = "https://files.pythonhosted.org/packages/6b/cb/5c7e9231d6ba00e225623947068db09ddd4e401800b0afaf39eece14bfee/fastar-0.9.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d012644421d669d9746157193f4eafd371e8ae56ff7aef97612a4922418664c", size = 1034940, upload-time = "2026-03-20T14:25:58.893Z" }, - { url = "https://files.pythonhosted.org/packages/b5/b4/eccfcf7fe9d2a0cea6d71630acc48a762404058c9b3ae1323f74abcda005/fastar-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:094fd03b2e41b20a2602d340e2b52ad10051d82caa1263411cf247c1b1bc139f", size = 1073807, upload-time = "2026-03-20T14:26:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/8b/53/6ddda28545b428d54c42f341d797046467c689616a36eae9a43ba56f2545/fastar-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59bc500d7b6bdaf2ffb2b632bc6b0f97ddfb3bb7d31b54d61ceb00b5698d6484", size = 1025314, upload-time = "2026-03-20T14:26:24.624Z" }, - { url = "https://files.pythonhosted.org/packages/03/cf/71e2a67b0a69971044ad57fe7d196287ac32ab710bfc47f34745bb4a7834/fastar-0.9.0-cp312-cp312-win32.whl", hash = "sha256:25a1fd512ce23eb5aaab514742e7c6120244c211c349b86af068c3ae35792ec3", size = 452740, upload-time = "2026-03-20T14:26:56.604Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c5/0ffa2fffac0d80d2283db577ff23f8d91886010ea858c657f8278c2a222c/fastar-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b10a409797d01ee4062547e95e4a89f6bb52677b144076fd5a1f9d28d463ab10", size = 485282, upload-time = "2026-03-20T14:26:44.926Z" }, - { url = "https://files.pythonhosted.org/packages/14/20/999d72dc12e793a6c7889176fc42ad917d568d802c91b4126629e9be45a9/fastar-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea4d98fc62990986ce00d2021f08ff2aa6eae71636415c5a5f65f3a6a657dc5e", size = 461795, upload-time = "2026-03-20T14:26:36.728Z" }, - { url = "https://files.pythonhosted.org/packages/9a/26/ea9339facfe4ee224be673c6888dbf077f28b0f81185f80353966c9f4925/fastar-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7b55ae4a3a481fd90a63ac558a7e8aab652ac1dfd15d8657266e71bf65346408", size = 706740, upload-time = "2026-03-20T14:25:33.741Z" }, - { url = "https://files.pythonhosted.org/packages/77/52/f3b06867e5ca8d5b2c1c15a1563415e0037b5831f2058ee72b03960296d9/fastar-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f07c6bdeedfeb30ef459f21fa9ab06e2b6727f7e7653176d3abb7a85f447c400", size = 627615, upload-time = "2026-03-20T14:25:21.608Z" }, - { url = "https://files.pythonhosted.org/packages/52/32/021b0a633bca18bca4f831392c2938c15c4605de2d9895b783ad6d64679c/fastar-0.9.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:90f46492e05141089766699e95c79d470e8013192fbbb16ef16b576281f3b8ee", size = 864584, upload-time = "2026-03-20T14:24:56.941Z" }, - { url = "https://files.pythonhosted.org/packages/3f/54/e2e1b4c8512d670373047e5e585b1d1ff9ffd722b0a17647d22c9c9bd248/fastar-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:108bb46c080ca152bb331f1e0576177d36e9badba51b1d5724d2823542e0dd1f", size = 760246, upload-time = "2026-03-20T14:23:51.964Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7d/1e283dd8dbb3647049594bb477bdc053045c6fff2d3f06386d2dcacce7aa/fastar-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d17d311cfbb559154ba940972b6d07a3a7ac221a2a01208f119ad03495f01d32", size = 757024, upload-time = "2026-03-20T14:24:04.69Z" }, - { url = "https://files.pythonhosted.org/packages/87/ac/82d3cb64d318ce16c5d1a26a40b8aa570fcc9b23684221aece838c4cbada/fastar-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2ef34e7088f308e73460e1b8d9b0479a743f679816782a80db6ae87ee68714a", size = 921630, upload-time = "2026-03-20T14:24:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b8/3e7892f1a25a1a2054a20de6c846c0794b8fa361e5b9d3d00915b41e97bd/fastar-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c93bf4732d0dd6adae4a8b3bbebe19af76ee1072b7688bf39c5a1d120425a772", size = 815791, upload-time = "2026-03-20T14:24:43.28Z" }, - { url = "https://files.pythonhosted.org/packages/db/5e/8fcc662db1fd0985f4f8a54e79276416565a0d1fcb8da66665b2061ead30/fastar-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a67b061b1099cf3b8b6234dd3605fa16f5078ab6b51c8d77ad7a5d11c3cf834", size = 818980, upload-time = "2026-03-20T14:25:09.545Z" }, - { url = "https://files.pythonhosted.org/packages/68/ed/37291fbd6c9b5b0905712da6191bdfc25a7dc236efbf130e3a1a7d1b9440/fastar-0.9.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:912efe3121dc1f3c05940cfa1c6b09b8868d702d24566506aa1d0d96e429923a", size = 884578, upload-time = "2026-03-20T14:24:30.584Z" }, - { url = "https://files.pythonhosted.org/packages/94/19/7b3b7af978ae4f012664781554716d67549ab19ddbcb6e6d1adc04d7a5e7/fastar-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2394980cc126a3263e115600bc4ff9e7320cddde83c99fc334ab530be5b7166e", size = 967790, upload-time = "2026-03-20T14:25:46.975Z" }, - { url = "https://files.pythonhosted.org/packages/e6/38/4cce2a8e529a7d3e99e427c9bbcccd7013ff6b3ba295613e6f1c573c9e6c/fastar-0.9.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d0aff74ea98642784c941d3cd8c35943258d4b9626157858901c5b181683339b", size = 1033892, upload-time = "2026-03-20T14:26:00.22Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3f/86f25d79b1b369c2756ee338b76d1696a9cac3a737e819459b0ad7822ede/fastar-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3e8a1deaf490f4ec15eca7e66127ff89cdefd20217f358739d4b7b1cb322f663", size = 1072969, upload-time = "2026-03-20T14:26:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/10/4f/6ec0c123c15bbcb9a9b82e979dc81273789ebbfbb4a2b41a1a6941577c94/fastar-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9bd8879ebf05aa247e60e454bb7568cbdd44f016b8c58e31e5398039403e61d", size = 1025768, upload-time = "2026-03-20T14:26:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d1/cbdcdb78ca034ed51a9f53c2650885873d8b06727452c1cc33f56ad0c66a/fastar-0.9.0-cp313-cp313-win32.whl", hash = "sha256:11b35e6453a2da8715dd8415b3999ea57805125493e44ce41a32404bf9a510a7", size = 452742, upload-time = "2026-03-20T14:26:58.014Z" }, - { url = "https://files.pythonhosted.org/packages/74/ee/138d2f8e3504232a279afa224d3e5922c15dc7126613e6c135cfc8e10ec9/fastar-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:10a1e7f7bfa1c6f03e4c657fdc0a32ebe42d8e48f681403dc0c67258e1cb5bef", size = 484917, upload-time = "2026-03-20T14:26:46.135Z" }, - { url = "https://files.pythonhosted.org/packages/db/ca/f518ee9dccc45097560a2cff245590c65b7b348171c8d2f2e487cf92a69f/fastar-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:e5484ac1415e0ca8bc7b69231e3e3afb52887fed10b839ca676767635a13f06f", size = 461202, upload-time = "2026-03-20T14:26:37.937Z" }, - { url = "https://files.pythonhosted.org/packages/cf/00/99700dd33273c118d7d9ab7ad5db6650b430448d4cfae62aec6ef6ca4cb7/fastar-0.9.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ccb2289f24ee6555330eb77149486d3a2ec8926450a96157dd20c636a0eec085", size = 707059, upload-time = "2026-03-20T14:25:35.086Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a4/4808dcfa8dddb9d7f50d830a39a9084d9d148ed06fcac8b040620848bc24/fastar-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2bfee749a46666785151b33980aef8f916e6e0341c3d241bde4d3de6be23f00c", size = 627135, upload-time = "2026-03-20T14:25:23.134Z" }, - { url = "https://files.pythonhosted.org/packages/da/cb/9c92e97d760d769846cae6ce53332a5f2a9246eb07b369ac2a4ebf10480c/fastar-0.9.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f6096ec3f216a21fa9ac430ce509447f56c5bd979170c4c0c3b4f3cb2051c1a8", size = 864974, upload-time = "2026-03-20T14:24:58.624Z" }, - { url = "https://files.pythonhosted.org/packages/84/38/9dadebd0b7408b4f415827db35169bbd0741e726e38e3afd3e491b589c61/fastar-0.9.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7a806e54d429f7f57e35dc709e801da8c0ba9095deb7331d6574c05ae4537ea", size = 760262, upload-time = "2026-03-20T14:23:53.275Z" }, - { url = "https://files.pythonhosted.org/packages/d6/7d/7afc5721429515aa0873b268513f656f905d27ff1ca54d875af6be9e9bc6/fastar-0.9.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9a06abf8c7f74643a75003334683eb6e94fabef05f60449b7841eeb093a47b0", size = 757575, upload-time = "2026-03-20T14:24:06.143Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5d/7498842c62bd6057553aa598cd175a0db41fdfeda7bdfde48dab63ffb285/fastar-0.9.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e9b5c155946f20ce3f999fb1362ed102876156ad6539e1b73a921f14efb758c", size = 924827, upload-time = "2026-03-20T14:24:19.364Z" }, - { url = "https://files.pythonhosted.org/packages/69/ab/13322e98fe1a00ed6efbfa5bf06fcfff8a6979804ef7fcef884b5e0c6f85/fastar-0.9.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdedac6a84ef9ebc1cee6d777599ad51c9e98ceb8ebb386159483dcd60d0e16", size = 816536, upload-time = "2026-03-20T14:24:44.844Z" }, - { url = "https://files.pythonhosted.org/packages/fe/fd/0aa5b9994c8dba75b73a9527be4178423cb926db9f7eca562559e27ccdfd/fastar-0.9.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51df60a2f7af09f75b2a4438b25cb903d8774e24c492acf2bca8b0863026f34c", size = 818686, upload-time = "2026-03-20T14:25:10.799Z" }, - { url = "https://files.pythonhosted.org/packages/46/d6/e000cd49ef85c11a8350e461e6c48a4345ace94fb52242ac8c1d5dad1dfc/fastar-0.9.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:15016d0da7dbc664f09145fc7db549ba8fe32628c6e44e20926655b82de10658", size = 885043, upload-time = "2026-03-20T14:24:32.231Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/ee734fe273475b9b25554370d92a21fc809376cf79aa072de29d23c17518/fastar-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c66a8e1f7dae6357be8c1f83ce6330febbc08e49fc40a5a2e91061e7867bbcbf", size = 967965, upload-time = "2026-03-20T14:25:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/165b3a75f1ee8045af9478c8aae5b5e20913cca2d4a5adb1be445e8d015a/fastar-0.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1c6829be3f55d2978cb62921ef4d7c3dd58fe68ee994f81d49bd0a3c5240c977", size = 1034507, upload-time = "2026-03-20T14:26:01.518Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4e/4097b5015da02484468c16543db2f8dec2fe827d321a798acbd9068e0f13/fastar-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:68db849e01d49543f31d56ef2fe15527afe2b9e0fb21794edc4d772553d83407", size = 1073388, upload-time = "2026-03-20T14:26:14.448Z" }, - { url = "https://files.pythonhosted.org/packages/07/d7/3b86af4e63a551398763a1bbbbac91e1c0754ece7ac7157218b33a065f4c/fastar-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5569510407c0ded580cfeec99e46ebe85ce27e199e020c5c1ea6f570e302c946", size = 1025190, upload-time = "2026-03-20T14:26:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/39/07/8c50a60f03e095053306fcf57d9d99343bce0e99d5b758bf96de31aec849/fastar-0.9.0-cp314-cp314-win32.whl", hash = "sha256:3f7be0a34ffbead52ab5f4a1e445e488bf39736acb006298d3b3c5b4f2c5915e", size = 452301, upload-time = "2026-03-20T14:26:59.234Z" }, - { url = "https://files.pythonhosted.org/packages/ee/69/aa6d67b09485ba031408296d6ff844c7d83cdcb9f8fcc240422c6f83be87/fastar-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf7f68b98ed34ce628994c9bbd4f56cf6b4b175b3f7b8cbe35c884c8efec0a5b", size = 484948, upload-time = "2026-03-20T14:26:48.45Z" }, - { url = "https://files.pythonhosted.org/packages/20/6d/dba29d87ca929f95a5a7025c7d30720ad8478beed29fff482f29e1e8b045/fastar-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:155dae97aca4b245eabb25e23fd16bfd42a0447f9db7f7789ab1299b02d94487", size = 461170, upload-time = "2026-03-20T14:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/96/8f/c3ea0adac50a8037987ee7f15ff94767ebb604faf6008cbd2b8efa46c372/fastar-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a63df018232623e136178953031057c7ac0dbf0acc6f0e8c1dc7dbc19e64c22f", size = 705857, upload-time = "2026-03-20T14:25:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b3/e0e1aad1778065559680a73cdf982ed07b04300c2e5bf778dec8668eda6f/fastar-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6fb44f8675ef87087cb08f9bf4dfa15e818571a5f567ff692f3ea007cff867b5", size = 626210, upload-time = "2026-03-20T14:25:24.361Z" }, - { url = "https://files.pythonhosted.org/packages/94/f3/3c117335cbea26b3bc05382c27e6028278ed048d610b8de427c68f2fec84/fastar-0.9.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81092daa991d0f095424e0e28ed589e03c81a21eeddc9b981184ddda5869bf9d", size = 864879, upload-time = "2026-03-20T14:25:00.131Z" }, - { url = "https://files.pythonhosted.org/packages/26/5d/e8d00ec3b2692d14ea111ddae25bf10e0cb60d5d79915c3d8ea393a87d5c/fastar-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e8793e2618d0d6d5a7762d6007371f57f02544364864e40e6b9d304b0f151b2", size = 759117, upload-time = "2026-03-20T14:23:54.826Z" }, - { url = "https://files.pythonhosted.org/packages/1a/61/6e080fdbc28c72dded8b6ff396035d6dc292f9b1c67b8797ac2372ca5733/fastar-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83f7ef7056791fc95b6afa987238368c9a73ad0edcedc6bc80076f9fbd3a2a78", size = 756527, upload-time = "2026-03-20T14:24:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/e8/97/2cf1a07884d171c028bd4ae5ecf7ded6f31581f79ab26711dcdad0a3d5ab/fastar-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3a456230fcc0e560823f5d04ae8e4c867300d8ee710b14ddcdd1b316ac3dd8d", size = 921763, upload-time = "2026-03-20T14:24:20.787Z" }, - { url = "https://files.pythonhosted.org/packages/f6/e3/c1d698a45f9f5dc892ed7d64badc9c38f1e5c1667048191969c438d2b428/fastar-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a60b117ebadc46c10c87852d2158a4d6489adbfbbec37be036b4cfbeca07b449", size = 815493, upload-time = "2026-03-20T14:24:46.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/38/e124a404043fba75a8cb2f755ca49e4f01e18400bb6607a5f76526e07164/fastar-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6199b4ca0c092a7ae47f5f387492d46a0a2d82cb3b7aa0bf50d7f7d5d8d57f", size = 819166, upload-time = "2026-03-20T14:25:12.027Z" }, - { url = "https://files.pythonhosted.org/packages/85/4a/5b1ea5c8d0dbdfcec2fd1e6a243d6bb5a1c7cd55e132cc532eb8b1cbd6d9/fastar-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:34efe114caf10b4d5ea404069ff1f6cc0e55a708c7091059b0fc087f65c0a331", size = 883618, upload-time = "2026-03-20T14:24:33.552Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/ae46e5722a67a3c2e0ff83d539b0907d6e5092f6395840c0eb6ede81c5d6/fastar-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4d44c1f8d9c5a3e4e58e6ffb77f4ca023ba9d9ddd88e7c613b3419a8feaa3db7", size = 966294, upload-time = "2026-03-20T14:25:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/98/58/b161cf8711f4a50a3e57b6f89bc703c1aed282cad50434b3bc8524738b20/fastar-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d2af970a1f773965b05f1765017a417380ad080ea49590516eb25b23c039158a", size = 1033177, upload-time = "2026-03-20T14:26:02.868Z" }, - { url = "https://files.pythonhosted.org/packages/e2/76/faac7292bce9b30106a6b6a9f5ddb658fdb03abe2644688b82023c8f76b9/fastar-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1675346d7cbdde0d21869c3b597be19b5e31a36442bdf3a48d83a49765b269dc", size = 1073620, upload-time = "2026-03-20T14:26:16.121Z" }, - { url = "https://files.pythonhosted.org/packages/b8/be/dd55ffcc302d6f0ff4aba1616a0da3edc8fcefb757869cad81de74604a35/fastar-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc440daa28591aeb4d387c171e824f179ad2ab256ce7a315472395b8d5f80392", size = 1025147, upload-time = "2026-03-20T14:26:28.767Z" }, - { url = "https://files.pythonhosted.org/packages/4b/c7/080bbb2b3c4e739fe6486fd765a09905f6c16c1068b2fcf2bb51a5e83937/fastar-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:32787880600a988d11547628034993ef948499ae4514a30509817242c4eb98b1", size = 452317, upload-time = "2026-03-20T14:27:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/42/39/00553739a7e9e35f78a0c5911d181acf6b6e132337adc9bbc3575f5f6f04/fastar-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92fa18ec4958f33473259980685d29248ac44c96eed34026ad7550f93dd9ee23", size = 483994, upload-time = "2026-03-20T14:26:52.76Z" }, - { url = "https://files.pythonhosted.org/packages/4f/36/a7af08d233624515d9a0f5d41b7a01a51fd825b8c795e41800215a3200e7/fastar-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:34f646ac4f5bed3661a106ca56c1744e7146a02aacf517d47b24fd3f25dc1ff6", size = 460604, upload-time = "2026-03-20T14:26:40.771Z" }, - { url = "https://files.pythonhosted.org/packages/69/9f/4aeaa0a1ac2aca142a276ea136e651e94ba1341bd840ba455ed250d1970b/fastar-0.9.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b74ce299066288f3b90221dca8507f59c7d9e8df91387948006b9a0fea4f9bdc", size = 710738, upload-time = "2026-03-20T14:25:41.17Z" }, - { url = "https://files.pythonhosted.org/packages/d0/19/9f8fb5c0e803254c5d535c362102dd604d9bdb206d5a36150f4637cadf09/fastar-0.9.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76be31936cabce31cbb6381128f851cf0a6da2d5c25357615cd1504b26dc31cf", size = 633000, upload-time = "2026-03-20T14:25:28.496Z" }, - { url = "https://files.pythonhosted.org/packages/ef/8d/0d1d9a87a78f1e686bb6c7c69688a4c9ad1efb65e49cc66310b97fdf900b/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c4c9ea0e0d69445b0ca3b0bd80bd8237fec8a914275b0472ecca2b555c12f3a3", size = 871226, upload-time = "2026-03-20T14:25:04.351Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/366937320b1cca522570c527a45b1254bd68d057e68956baefc49eacae27/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b665c33afcd1d581b82235b690d999c5446ccc2c4d80c4a95f30df3b43d22494", size = 763872, upload-time = "2026-03-20T14:23:59.122Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f2/121c5432bb152da68fc466a0d0206d66383a40a2f9beff5583d9277aceee/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2a9a49f9217f4f60f9ba23fdd1f7f3f04fed97391145eb9460ec83ca0b4bd33", size = 762897, upload-time = "2026-03-20T14:24:11.932Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/88d3a603b997063e032f94cc0fff74031d76903f38cc30416a400395df03/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d860e82a531e9cc67e7f500a299bffbe6e93d80bbf48401fd8f452a0c58f28", size = 927024, upload-time = "2026-03-20T14:24:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/a6/17/d6dc778c45b0c7d9a279706d7a5d62122dab0a7a0cb39aac6f5ef42f13f6/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3feede2d72ec0782b5ccc18568f36cbe33816be396551aa47b3e1b73c322cdd2", size = 821265, upload-time = "2026-03-20T14:24:50.407Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e0/cec25d43df7ea4b4e3e875352c6d51c848c855792ba276c546732a7170af/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9ac410d32cbb514e966c45f0fedd0f9447b0dea9e734af714648da503603df6", size = 824024, upload-time = "2026-03-20T14:25:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/52/90/c354969770d21d1b07c9281b5e23052392c288d22984a1917d30940e86cb/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:40b8c08df809e5e58d1839ccb37bafe4485deb6ee56bb7c5f0cbb72d701eb965", size = 888886, upload-time = "2026-03-20T14:24:38.229Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ac/eb2a01ed94e79b72003840448d2b69644a54a47f615c7d693432a1337caa/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d62a4fd86eda3bea7cc32efd64d43b6d0fcdbbec009558b750fc362f20142789", size = 972503, upload-time = "2026-03-20T14:25:54.207Z" }, - { url = "https://files.pythonhosted.org/packages/8d/88/f7e28100fa7ff4a26a3493ad7a5d45d70f6de858c05f5c34aca3570c5839/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:7bf6958bb6f94e5ec522e4a255b8e940d3561ad973f0be5dde6115b5a0854af5", size = 1039106, upload-time = "2026-03-20T14:26:07.686Z" }, - { url = "https://files.pythonhosted.org/packages/c0/de/52c578180fdaaf0f3289de8a878f1ac070f7e3e18a0689d3fd44dd7dae2c/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:c210b839c0a33cf8d08270963ad237bcb63029dddf6d6025333f7e5ca63930bd", size = 1080754, upload-time = "2026-03-20T14:26:20.299Z" }, - { url = "https://files.pythonhosted.org/packages/a4/45/1ea024be428ad9d89e9f738c9379507e97df9f9ed97e50e4a1d10ff90fef/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:fad70e257daefb42bab68dcd68beaf2e2a99da056d65f2c9f988449a4e869306", size = 1031304, upload-time = "2026-03-20T14:26:33.294Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4a/0d79fe52243a4130aa41d0a3a9eea22e00427db761e1a6782ee817c50222/fastar-0.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e7c906ad371ca365591ebcb7630009923f3eceb20956814494d15591a78e9e46", size = 709786, upload-time = "2026-04-13T17:09:53.974Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e4/77c94eaafc035e39f5ce5176e32743da4e3fe890f28790e708e53d8f75cd/fastar-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6919497b35fa5bd978d2c26ee117cf1771b90ee5073f7518e44b9bc364b57715", size = 632127, upload-time = "2026-04-13T17:09:39.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f6/97658dd992f4e45747d35adb24c0b100f6b6d451490685ae3fe8a3a2ee1b/fastar-0.11.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:56b50206aeedd99e22b83289e6fb3ff8f7d7da4407d2419902e4716b4f90585a", size = 869608, upload-time = "2026-04-13T17:09:08.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fc/81c1ec4d8146a437399e7b95631b51be312f323a9ce64569f932db6c3914/fastar-0.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a1811a69ae81d469720df0c8af3f84f834a93b5e4f8be0e0e8bde6a52fa11f2", size = 762925, upload-time = "2026-04-13T17:07:52.788Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/49baf480ecb197aea7ce2515c503a2f25061958dd3b4c98e98a3a11cdcc7/fastar-0.11.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10486238c55589a3947c38f9cfb88a67d8a608eb8dddc722038237d0278a41d7", size = 759913, upload-time = "2026-04-13T17:08:07.324Z" }, + { url = "https://files.pythonhosted.org/packages/94/eb/946f1980267f2824efb7d7c518d47a49b89c0e9cd7c449301f5a7531558a/fastar-0.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1555ef9992d368a6ec39092276990cef8d329c39a1d86ebd847eaa3b10efd472", size = 926054, upload-time = "2026-04-13T17:08:22.196Z" }, + { url = "https://files.pythonhosted.org/packages/0c/19/d5eb611085ce054382570d8d4e24a5e2ff23cd6d2404528a6643841d6059/fastar-0.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1f4aca0a9620b76988bbf6225cdea6678a392902444ca18bb8a51495b165a89", size = 818594, upload-time = "2026-04-13T17:08:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/4a/52/18e8d55c0d3d917713f381cb2d0cb793da00c209c802e011d8dc72018cd5/fastar-0.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75beeecac7d11a666a6c4a0b7f7e80842ae5cf523f2f890b99c78fc82b403545", size = 823005, upload-time = "2026-04-13T17:09:23.051Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b4/0fecdcf33e5aaffe777b96a1c10a3204fe0b05bf18e971033a0bfedafc1c/fastar-0.11.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a08cdf5d16daa401c65c9c7493a18db7dc515c52155a17071ec7098bb07da9d3", size = 887115, upload-time = "2026-04-13T17:08:37.385Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/2a6ad1c2523eb72a4595a9331162fc67ce0f0aee3348728598026c516986/fastar-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6e210375e5a7ba53586cbd6017aa417d2d2ceacbe8671682470281bd0a15e8ef", size = 973595, upload-time = "2026-04-13T17:10:09.258Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/2aa48843228673feacc2b80876b8924e63ea9c5f5f607bd7a72416b86bae/fastar-0.11.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a2988eb2604b8e15670f355425e8c800e4dcd4edfbcbfe194397f8f17b7eb19e", size = 1036988, upload-time = "2026-04-13T17:10:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/92/ac/3dd14b21c323e8484f47c910110d1d93139ba44621ac2c4c597dbe9fcdb7/fastar-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:34abc857b46068fdf91d157bd0203bfd6791dc7a432d1ed180f5af6c2f5bcce9", size = 1078267, upload-time = "2026-04-13T17:10:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/de/a1/3f89e58d6fa99160c9e7e17220c8ab5040b5cc017c4fac2356c6ed18453d/fastar-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0d884be84e37a01053776395441fc960031974e0265801ce574efc3d05e0cdaf", size = 1032551, upload-time = "2026-04-13T17:11:00.667Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ea/24dd3cfc2096933d7d2a80c926e79602cff1fa481124ed2165b60c1dd9ef/fastar-0.11.0-cp310-cp310-win32.whl", hash = "sha256:c721c1ad758e3e4c2c1fd9e96911a0fa58c0a6be5668f1bcfd0b741e72c7cb63", size = 456022, upload-time = "2026-04-13T17:11:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/82/ef/6eb39ee9cdd59822d1c7337c4d28fdc948885bdf455af9e70efa9879e06f/fastar-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba4180b7c3080f55f9035fdd7d8c39fe0e1485087a68ff615bb4784a10b8106b", size = 488392, upload-time = "2026-04-13T17:11:27.486Z" }, + { url = "https://files.pythonhosted.org/packages/11/7a/fb367bdaf4efa2c7952a45aeab2e87a564293ecffe150af673ec8edfda46/fastar-0.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b82fd6f996e65a86f67a6bd64dd22ef3e8ae2dcaed0ae3b550e71f7e1bbb1df5", size = 709869, upload-time = "2026-04-13T17:09:55.62Z" }, + { url = "https://files.pythonhosted.org/packages/80/ff/b87efb0dcfd081c62c7c7601d7681dabe63103cd51fc16f8d57a1ab45961/fastar-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27eed386fd0558e6daa29211111bbd7b740f7c7e881197f8a00ac7c0f3cdb1d7", size = 631668, upload-time = "2026-04-13T17:09:40.537Z" }, + { url = "https://files.pythonhosted.org/packages/24/7c/0ed6dd38b9adc04b3a8ec3b7045908e7c2170ba0ff6e6d2c51bc9fc770f3/fastar-0.11.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a6931bebc1d8e95ddeef55732c195449e6b44ef33aa31b325505097ed3b4d6aa", size = 869663, upload-time = "2026-04-13T17:09:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/8b7fb3f23855accebaaf2d2637eac7f261a7a5d936f861a172079f1ef511/fastar-0.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f72ce42a5e28a74fbd4d5fbf1a3ac1a1163d13cbc200cbd005fb0fabc54bd", size = 762938, upload-time = "2026-04-13T17:07:54.51Z" }, + { url = "https://files.pythonhosted.org/packages/07/cc/5491e2b677bb841f768e3aba052d0344338a5c78aa5d4c18b443831a8e8d/fastar-0.11.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b83c1f61f7017d6e1498568038f8745440cfc16ca2f697ec81bac83050108f6", size = 759232, upload-time = "2026-04-13T17:08:08.864Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/643630bdbd179e41e9fae31c03b4cf6061dbf4d6fbbae8425d16eb12545d/fastar-0.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db73a9b765a516e73983b25341e7b5e0189733878279e278b2295131b0e3a21e", size = 926271, upload-time = "2026-04-13T17:08:23.68Z" }, + { url = "https://files.pythonhosted.org/packages/09/5d/37ade50003b4540e0a53ef100f6692d7ab2ac1122d5acf39920cc09a3e8b/fastar-0.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:625827d52eb4e8fec942e0233f125ff8010fcf6a67c0a974a8e5f4666b771e3c", size = 818634, upload-time = "2026-04-13T17:08:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ff/135d177de32cc1e837c99019e4643e6e79352bde49544d4ece5b5eebf56b/fastar-0.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7f5fd8fa21ec0a88296a38dc5d7fc35efd3b26d46a17b8b7c73c5563925ca15", size = 822755, upload-time = "2026-04-13T17:09:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/27/cb/b835dbe76ceac7fa6105851468c259ffd06830eb9c029402e499d0ec153b/fastar-0.11.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8c15af91b8cd87ddf23ea55355ae513c1de3ab67178f26dad017c9e9c0af6096", size = 887101, upload-time = "2026-04-13T17:08:39.248Z" }, + { url = "https://files.pythonhosted.org/packages/9e/54/aa8289eb57fc550535470397cb051f5a58a7c89ca4de31d5502b916dd894/fastar-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a112395a8b0bff251423bd1564c012f0cc058ad8b6bd8fba96f3d7fc117e44", size = 973606, upload-time = "2026-04-13T17:10:10.98Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/776d50a0897c01dc6bfd0926772ee913436fdae91b9affaf0a0cbd09f0a1/fastar-0.11.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2994bb8f5f8c11eb12beae1e6e77a907173c9819236b8a4c8f0573652ceccce", size = 1036696, upload-time = "2026-04-13T17:10:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/cf0f9b499fb37ac065c8a01ec642f96a3c5eb849c38ae983b59f3b3245e0/fastar-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dcf99e4b5973d842c7f19c776c3a83cdc0977d505edce6206438505c0456b517", size = 1078182, upload-time = "2026-04-13T17:10:45.318Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9e/21e4701aec4a1123d4dc4d31578dc18875582b5710e4725f7ceb752a248b/fastar-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29c9c386dc0d5dda78845a8e6b1480d26ab861c1e0b68f42ae5735cb70ca07f1", size = 1032336, upload-time = "2026-04-13T17:11:02.364Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/5872b28c72c27ec1a00760eace6ff35f714f41ebbd5208cf016b12e29250/fastar-0.11.0-cp311-cp311-win32.whl", hash = "sha256:030b2580fc394f2c9b7890b6735810404e9b9ed5e0344db150b945965b5482b7", size = 457368, upload-time = "2026-04-13T17:11:43.528Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6e/ce6832a16193eb4466f4108be8809c249b51cb1f89dd7894545700d079d5/fastar-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:83ab57ae067969cd0b483ac3b6dccc4b595fc77f5c820760998648d4c42822b5", size = 488605, upload-time = "2026-04-13T17:11:29.161Z" }, + { url = "https://files.pythonhosted.org/packages/15/5a/9cfb80661cf38fd7b0889224beb7d2746784d4ade2a931ed9775a18d8602/fastar-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:27b1a4cee2298b704de8151d310462ee7335ed036011ca9aa6e784b30b6c73a9", size = 464580, upload-time = "2026-04-13T17:11:18.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, + { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, + { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5c/9bbeffbf1905391446dd98aa520422ce7affde5c9a7c22d757cc5d7c1397/fastar-0.11.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1266d6a004f427b0d61bd6c7b544d84cc964691b2232c2f4d635a1b75f2f6d5e", size = 711644, upload-time = "2026-04-13T17:10:07.663Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/ae5cf39d4fb82d0c592705f5ec6db1b065be5265c151b108f86126ee8773/fastar-0.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:298a827ec04ade43733f6ca960d0faec38706aa1494175869ea7ea17f5bad5d3", size = 634371, upload-time = "2026-04-13T17:09:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/7e/36/8d4569e26473c72ccb02d1c5df3ed710073f1c06eca09c26d52ea79fd815/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8800e2387e463a0e5799416a1cbe72dd0fde7270a20e4bde684145e7878f6516", size = 870850, upload-time = "2026-04-13T17:09:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/724dc796e1756d3977970f820d30d59bb8cab8e3671b285f1d82ab513aec/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7496def0a2befd82d429cb004ef7ca831585cc887947bd6b9abb68a5ef852b0b", size = 764469, upload-time = "2026-04-13T17:08:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/99/e3/74d6859e632e8fb9339a14f652fb9f800c2bd6aa53071e311c0be3fbab8b/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:878eaf15463eb572e3538af7ca3a8534e5e279cf8196db902d24e5725c4af86e", size = 761375, upload-time = "2026-04-13T17:08:20.669Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e7/cc70e2be5ef8731a7525552b1c35c1448cf9eae6a62cb3a56f12c1bf27ea/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0324ed1d1ef0186e1bbd843b17807d6d837d0906899d4c99378b02c5d86bdd9c", size = 928189, upload-time = "2026-04-13T17:08:35.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/33/c9a969e78dca323547276a6fee5f4f9588f7cd5ab45acec3778c67399589/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdf9bd863205590beaf8ef6e66f315310196632180dceaf674985d01a876cac3", size = 820864, upload-time = "2026-04-13T17:09:06.366Z" }, + { url = "https://files.pythonhosted.org/packages/84/bd/6b9434b541fe55c125b5f2e017a565596a2d215aa09207e4555e4585064f/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59af8dbb683b24b90fb5b506de080faeab0a17a908e6c2a5d93a97260ed75d7b", size = 824060, upload-time = "2026-04-13T17:09:37.377Z" }, + { url = "https://files.pythonhosted.org/packages/24/8d/871d5f8cf4c6f13987119fb0a9ae8be131e34f2756c2524e9974adf33824/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:9f3df73a3c4292cfe15696cdf59cdb6c309ab59d30b34c733be13c6e32d9a264", size = 889217, upload-time = "2026-04-13T17:08:50.884Z" }, + { url = "https://files.pythonhosted.org/packages/d0/26/cca0fd2704f3ed20165e5613ed911549aef3aaf3b0b5b02fee0e8e23e6cc/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa3762cbb16e41a76b61f4a6914937a71aab3a7b6c2d82ca233bc686ebaf756b", size = 975418, upload-time = "2026-04-13T17:10:24.307Z" }, + { url = "https://files.pythonhosted.org/packages/99/94/8bbb0b13f5b6cbe2492f0b7cbba5103e6163976a3331466d010e781fa189/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:a8c7bc8ac74cb359bb546b199288c83236372d094b402e557c197e85527495cd", size = 1038492, upload-time = "2026-04-13T17:10:41.939Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d3/5b7df222a30eac2822ffd00f82fd4c2ce84fba4b369d1e1a03732fd177fc/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:587cbd060a2699c5f66281081395bb4657b2b1e0eef5c206b1aabf740019d670", size = 1080210, upload-time = "2026-04-13T17:10:58.462Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/56ef943ea524784598c035ccbd42e564e937da0438ae3f55f0e76cb95571/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a1c56957ac82408be37a3f63594bc83e0919e8760492a4475e542f9f1828778", size = 1034886, upload-time = "2026-04-13T17:11:15.617Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.73.1" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] name = "greenlet" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, - { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, - { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, - { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, - { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, - { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, - { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, - { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, - { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, - { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, - { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, - { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", size = 283510, upload-time = "2026-05-20T13:12:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", size = 599696, upload-time = "2026-05-20T14:00:02.906Z" }, + { url = "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", size = 612618, upload-time = "2026-05-20T14:05:39.202Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", size = 612947, upload-time = "2026-05-20T13:14:23.469Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", size = 1571425, upload-time = "2026-05-20T14:02:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", size = 1638688, upload-time = "2026-05-20T13:14:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", size = 237763, upload-time = "2026-05-20T13:11:35.659Z" }, + { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, + { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", size = 234989, upload-time = "2026-05-20T13:08:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, ] [[package]] name = "grpcio" -version = "1.78.0" +version = "1.81.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, - { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, - { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, - { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, - { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, - { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, - { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, - { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, - { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, - { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/15/f3/23f47b24f8d8c2028eba501db3acfbb2f592cbb5995eaa6e363a627b74d7/grpcio-1.81.0.tar.gz", hash = "sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5", size = 13032272, upload-time = "2026-06-01T05:56:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/a0/13f7dd9602a44c2852eb5ca29dfcb14de5547e1d37672dbf20e3cf17d5d2/grpcio-1.81.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:b4108e5d9d0f651b7eea749116181fe6c315b145661a80ec31f05ec2dbe21af7", size = 6087534, upload-time = "2026-06-01T05:54:04.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/439070efa430b3c51c8e319b67521957688905f27b294302c6077e9d4ef5/grpcio-1.81.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:b76ea9d55cd08fcdbda25d28e0f76679536710acb7fbd5b1f70cb4ac49317265", size = 12062452, upload-time = "2026-06-01T05:54:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6f/7802953eb46ab7082f70a139dac02a5544e8b784c4647f9750af28f64348/grpcio-1.81.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4e032feb3bfb4e2749b140a2302a6baa8ead1b9781ff5cf7094e4402b5e9372e", size = 6635199, upload-time = "2026-06-01T05:54:12.739Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/91d7fd2392923407fc89e7f1493011dacd3f1a6972cff5fa2237ac1efd5d/grpcio-1.81.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:725801c7086d7e4cd160e42bb2f54e0aeb976b9568df3cc6f843b15d29b79fb1", size = 7333482, upload-time = "2026-06-01T05:54:15.474Z" }, + { url = "https://files.pythonhosted.org/packages/9a/df/ec0a4e04472df2618f8741151fa026bc877648e952ebb0e421169e0b992b/grpcio-1.81.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f750a091fff3a3991731abc1f818bdc64874bb3528162732cb4d45f2e07821a6", size = 6837709, upload-time = "2026-06-01T05:54:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/86/82/9f69147bbd723ff07fea0242e5877a9026be1819410996e6086aae8f00a6/grpcio-1.81.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8226ba097eed660ef14d36c6a69b85038552bb8b6d17b44a5aa6f9abf48b8e08", size = 7440601, upload-time = "2026-06-01T05:54:20.662Z" }, + { url = "https://files.pythonhosted.org/packages/89/3b/52c1558e94941022b7ee046583fe4a007164c7e18087d55f82fd23c567b8/grpcio-1.81.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:40edffb4ec3689373825d367c4457727047a6e554f03245265ecc8cc03215f22", size = 8442803, upload-time = "2026-06-01T05:54:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5d/1264d086c5d3cc81c59084de1ccc87d1a037f91ce9cb1f611caaa19b70cc/grpcio-1.81.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f85570a016d794c29b1e76cf22f67af4486ddbe779e0f30674f138fa4e1769ec", size = 7868964, upload-time = "2026-06-01T05:54:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/3b3339e661669d545f09ee7ea33fec3b1b438e623b3105597d3457c39391/grpcio-1.81.0-cp310-cp310-win32.whl", hash = "sha256:3755c9669307cad18e7e009860fdea98118978d2300451bd8530a53048e741e7", size = 4202292, upload-time = "2026-06-01T05:54:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c3/cd81087855dfd4bbef2db50e58e1f7ce93a9a1675bc89a6cb76aa438ffaa/grpcio-1.81.0-cp310-cp310-win_amd64.whl", hash = "sha256:909bb3222b53235498d2c5817a0596d82b0aaea490ba93fdf1b060e2938a543c", size = 4937038, upload-time = "2026-06-01T05:54:30.376Z" }, + { url = "https://files.pythonhosted.org/packages/45/a8/9916ab10a0201f4c7afb6918125aa2f38a7626ee18ffbc066dd9cb04a74d/grpcio-1.81.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:794e6aa648e8df47d8f908dc8c3b42347d04ec58438f1dcd4e445f09b4f6b0ce", size = 6093557, upload-time = "2026-06-01T05:54:32.64Z" }, + { url = "https://files.pythonhosted.org/packages/a7/43/99e969a048904a65df3129ee53c5f523b7c4e43127786460cac4bee82470/grpcio-1.81.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cd78145b7f7784661c524624f3526c9c6f891b30a4b54cb93a40806d0d0d61e9", size = 12075345, upload-time = "2026-06-01T05:54:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/4c3a204e190333768d4f63f4ff56bd0bf405f05b9188f3a59a8bcf161f8b/grpcio-1.81.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:638ccc1b86f7540170a169cb900799b9296a1381e47879ce60b0de9d3db73d33", size = 6640664, upload-time = "2026-06-01T05:54:38.854Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a9/0fa17ac8b4e29cf59b26915be6cab8c0d4583ce24a6208a287b6e5f6d072/grpcio-1.81.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:21ec30b9ea320c8207ea7cd05873ad64aa69fdd0e81b6758b3347983ba20b50a", size = 7332542, upload-time = "2026-06-01T05:54:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/f4/18/7c8e3d0dda2fb7a17076fcd6c9085209eabad3354696c64230f87b3a14eb/grpcio-1.81.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dbdb99986548a7e87f8343805ef315fd4eb50ffaabf4fb1206e42f2542bb805d", size = 6842564, upload-time = "2026-06-01T05:54:43.57Z" }, + { url = "https://files.pythonhosted.org/packages/f6/19/2f1726c2e03ad3f3fe241e6b41534532ad580d595de14a4054ad84999c80/grpcio-1.81.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c36f5d5e97944cbda2d4096b4ae262e6e68506246b61582acf1b8591607f3ccc", size = 7446236, upload-time = "2026-06-01T05:54:46.042Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/0321f892212e2c0bfe248cea24c00d7d7111639688ec5ffd8e36b5c02fe6/grpcio-1.81.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f355384e5543ab77a755a7085225ecc19f32b76032e851cbd8145715d79dec8", size = 8445633, upload-time = "2026-06-01T05:54:48.809Z" }, + { url = "https://files.pythonhosted.org/packages/e5/20/0e7ea7494955cf1beea3077b2fd2c04c84d4480c2ae85a1e1cfa150c62d7/grpcio-1.81.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:77eb4e9fe61486bd1198cc7236ebb0f70e66234e63c0348f40bc2553ed16a88b", size = 7873958, upload-time = "2026-06-01T05:54:52.135Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/6438e226046c2a0778060e2b1d791a4827277bbd9d223013c2c63ee7435e/grpcio-1.81.0-cp311-cp311-win32.whl", hash = "sha256:7915a2e63acdc05264a206e1bddfd8e1fb8a29e406c18d72d30f8c124e021374", size = 4202110, upload-time = "2026-06-01T05:54:54.134Z" }, + { url = "https://files.pythonhosted.org/packages/42/6b/d0895e93d65b186f5f1737fcc186d7faa487e2d9d934eda111a37a309869/grpcio-1.81.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e925a70fe99fe5794f7beca0ea034c75f068afcc356d79047e73f99cdcca34c", size = 4940942, upload-time = "2026-06-01T05:54:56.749Z" }, + { url = "https://files.pythonhosted.org/packages/82/d5/896a3aaf07068d707d88b282a04914b872db4d32d3c7e6d88e43a3b911fa/grpcio-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c", size = 6053538, upload-time = "2026-06-01T05:54:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/7e3eafa4727cd405ff917605ed2949e2af162f233f5cbdd773723a5fea7d/grpcio-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce", size = 12053447, upload-time = "2026-06-01T05:55:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/79/a4302aa82428de48a922421f522b027a1a727ab4d0926368454aa953d36d/grpcio-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d", size = 6595872, upload-time = "2026-06-01T05:55:04.946Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1f/7ff2850eaefbecf99af3f624dbb28dd1ad6c5fd4c1d8c26909ed6482673b/grpcio-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8", size = 7303857, upload-time = "2026-06-01T05:55:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/1f3896a9baae1f2aedf4e99c55291d6fa1f30ad9603d63bc18bda967b53e/grpcio-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22", size = 6809676, upload-time = "2026-06-01T05:55:09.513Z" }, + { url = "https://files.pythonhosted.org/packages/34/8b/3441983718095208c5d797fd3239882e97ea89a629f41c8df94b4eef4df9/grpcio-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f", size = 7412654, upload-time = "2026-06-01T05:55:12.777Z" }, + { url = "https://files.pythonhosted.org/packages/3c/98/1eddf07df6e4fe85cf67502a793f7b05468b2dca3d1ef35b972cf5d54468/grpcio-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696", size = 8408026, upload-time = "2026-06-01T05:55:15.514Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/3860341e6a1f5347be6ab35c6c0e1e3a8eb59d010388207fd561dcf01a88/grpcio-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb", size = 7849498, upload-time = "2026-06-01T05:55:18.078Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/0ea06bd85c701966aa3f8f37314f2ed83520d2b7590f42d643d445d8bc8b/grpcio-1.81.0-cp312-cp312-win32.whl", hash = "sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa", size = 4184161, upload-time = "2026-06-01T05:55:20.127Z" }, + { url = "https://files.pythonhosted.org/packages/39/e3/a7c387406827a86f99ad7838b995bf9b4a182ffe2d2c439ed2873efec952/grpcio-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141", size = 4929958, upload-time = "2026-06-01T05:55:22.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/779ee53c931d0fd55c1d459fde43e485172caa3ac87cbd43d003a13a0185/grpcio-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855", size = 6054973, upload-time = "2026-06-01T05:55:25.043Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/7211807926b5a17f8d9a5d47c739a163d6812fefe3e4714e81cf92945ed7/grpcio-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719", size = 12048662, upload-time = "2026-06-01T05:55:28.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/89/b1b93ef6b34bd20bbaf707fa99133bc9cc302139d5ec6f77a165c7169796/grpcio-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901", size = 6599116, upload-time = "2026-06-01T05:55:31.185Z" }, + { url = "https://files.pythonhosted.org/packages/eb/bc/c89f9b9d1c22895715356a1e009554dae66319e97826bb4d30bcda7d29e8/grpcio-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279", size = 7307591, upload-time = "2026-06-01T05:55:33.463Z" }, + { url = "https://files.pythonhosted.org/packages/65/4a/1df2a4cb4a1386e066ab7e4175e34bb884b35ccb60d3621c09c84af6aabb/grpcio-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170", size = 6811797, upload-time = "2026-06-01T05:55:36.731Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa189d20601a1be25b08850cfb733879bbb1047b62a8feec3a60e3e1a87b/grpcio-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc", size = 7415131, upload-time = "2026-06-01T05:55:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/5625c48cb48d23c6631b3e5294f88e4c751f22a52591ae78859fab96dca1/grpcio-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47", size = 8408398, upload-time = "2026-06-01T05:55:42.219Z" }, + { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/28b231333857deb840bc3d182ae087510170ea6d68f21393aeb0fe499530/grpcio-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390", size = 6055712, upload-time = "2026-06-01T05:55:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/999c14f9dff0fc47549d2e827cba1343ddc18e1d1bf0d06d2cf628eecbd9/grpcio-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2", size = 12057189, upload-time = "2026-06-01T05:55:55.952Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3d/1fbde079572562af65351151d840525a13879eb7b481d35b55cd64c6127a/grpcio-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5", size = 6608136, upload-time = "2026-06-01T05:55:59.069Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/1f17cb6882abfd8e5a303a25d5d1665abef5a8c499a96198c65a651d1b85/grpcio-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b", size = 7307045, upload-time = "2026-06-01T05:56:02.376Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/f98e91b2e755652e637ea2144318b0229b290062199f761b445fe1fa6015/grpcio-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf", size = 6812794, upload-time = "2026-06-01T05:56:05.777Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/77892d715ac41e7ec0ace2a50080ffb64e189188056f607a66fe0014d1ee/grpcio-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5", size = 7422767, upload-time = "2026-06-01T05:56:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b8/aa04590c6564714d94954515f15a236e59d4b9b3ad01e615f1b706d7792d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757", size = 8408551, upload-time = "2026-06-01T05:56:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/43/3d/4f4a3450a1973568910c6909cb74abbf2126f68aefae5976962f9f7ad50d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe", size = 7846468, upload-time = "2026-06-01T05:56:14.536Z" }, + { url = "https://files.pythonhosted.org/packages/88/f4/5827fd248221ad3b44161c23ce9b5f4ee405b04fc6da5fd402a9aa87a84a/grpcio-1.81.0-cp314-cp314-win32.whl", hash = "sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664", size = 4264427, upload-time = "2026-06-01T05:56:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/127dc2b246096ad50ef7c8d9b7b31d757787aeb796368bcdd4454e4204c4/grpcio-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b", size = 5070848, upload-time = "2026-06-01T05:56:19.735Z" }, ] [[package]] @@ -645,45 +709,52 @@ wheels = [ [[package]] name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, - { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, - { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -703,23 +774,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" +version = "3.18" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -775,16 +834,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" }, ] +[[package]] +name = "legacy-cgi" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -892,32 +972,31 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.40.0" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.40.0" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.40.0" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -928,14 +1007,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.40.0" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -946,14 +1025,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.61b0" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -961,14 +1040,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, ] [[package]] name = "opentelemetry-instrumentation-asgi" -version = "0.61b0" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, @@ -977,14 +1056,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/b5/7ea3a9fd1b80e89786c14250bfaecf32a753c3fd08232690f4da8dc16e29/opentelemetry_instrumentation_asgi-0.63b1.tar.gz", hash = "sha256:267b422416d768f3c7f4054883b41d9c3a7c943d86d20032b738c99a3dbb5862", size = 26151, upload-time = "2026-05-21T16:36:18.368Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/78/154470cf9d741a7487fbb5067357b87386475bbb77948a6707cae982e158/opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4", size = 16980, upload-time = "2026-03-04T14:19:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/57/7e/83986f27b421de04fab1e1a84e892621dac42e6432a9c66779505f4d1381/opentelemetry_instrumentation_asgi-0.63b1-py3-none-any.whl", hash = "sha256:1a22453dfa965f14799b10a674b8acbcb897a8a75c79136060af54214cc7886e", size = 15906, upload-time = "2026-05-21T16:35:04.162Z" }, ] [[package]] name = "opentelemetry-instrumentation-fastapi" -version = "0.61b0" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -993,66 +1072,75 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/d6/0c128fac2e34b7d526a8d3c6edc45b875a97f8a987861b00511151b6337d/opentelemetry_instrumentation_fastapi-0.63b1.tar.gz", hash = "sha256:cc42dff56c96d0a2921510c4abab2a4c2e27fe64b26dc1254727fb550df100ba", size = 25387, upload-time = "2026-05-21T16:36:32.071Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/05/acfeb2cccd434242a0a7d0ea29afaf077e04b42b35b485d89aee4e0d9340/opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c", size = 13485, upload-time = "2026-03-04T14:19:30.351Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/2eae63f13f36d7a8ab5bf03d06ecaf169c2069b524547f24947be6d92094/opentelemetry_instrumentation_fastapi-0.63b1-py3-none-any.whl", hash = "sha256:52ee2cde9a2ac094bdd45d79f85860e03a972928a2553006071fe61d94cf7281", size = 12795, upload-time = "2026-05-21T16:35:28.68Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.40.0" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.40.0" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.61b0" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, ] [[package]] name = "opentelemetry-util-http" -version = "0.61b0" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/d8/7bf5e4cec0578ac3c28c18eb7b88f34279139cbc8c568d6aa02b9c5ae53e/opentelemetry_util_http-0.63b1.tar.gz", hash = "sha256:ba1268f00922ee522dba2ae38458060f99486e7385a8056985901ca9685adfff", size = 11102, upload-time = "2026-05-21T16:36:56.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/34e047e8f6a3c67e5220acf1af7b9f62868c25d77791bca74457bd2180a6/opentelemetry_util_http-0.63b1-py3-none-any.whl", hash = "sha256:6284194028c59cd439f8acfe388145069a6127f11dc077e1344a2094adacc3f8", size = 8205, upload-time = "2026-05-21T16:36:09.736Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -1081,7 +1169,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1089,9 +1177,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -1101,120 +1189,118 @@ email = [ [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -1232,16 +1318,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -1255,7 +1341,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1266,9 +1352,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1294,11 +1380,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.30" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, ] [[package]] @@ -1389,7 +1475,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1397,9 +1483,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1417,15 +1503,15 @@ wheels = [ [[package]] name = "rich" -version = "14.3.3" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -1443,16 +1529,16 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.19.7" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/49/d7a4fd4f39c195b73f78694af3e812943a4181a8d48a11035425d0f6d71f/rich_toolkit-0.20.0.tar.gz", hash = "sha256:bb05382554d4f46865dfca2fccccf30768ef37e0347207d00f034d9b36b25021", size = 203144, upload-time = "2026-06-02T21:11:38.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" }, + { url = "https://files.pythonhosted.org/packages/67/b5/6b6efd9e305653fae68ed0b712bc659cd3c5541ec54416e6bb14af52acca/rich_toolkit-0.20.0-py3-none-any.whl", hash = "sha256:906e5b8741fafc46159c5f719fd30fd3c9dd8f2c31b8161dc8c612f98b8da01a", size = 35379, upload-time = "2026-06-02T21:11:37.564Z" }, ] [[package]] @@ -1578,15 +1664,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.56.0" +version = "2.61.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/3b/4bc6b348bbd331daa14d4babe9f2b99bc854f4da41560eefb9488d78481d/sentry_sdk-2.61.1.tar.gz", hash = "sha256:9c6adccb3feefa9ba032c8d295ca477575c2f11896046a2b0ad686c47c4af555", size = 459429, upload-time = "2026-06-01T07:24:18.875Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, + { url = "https://files.pythonhosted.org/packages/df/54/c9218db183846e08efaf68534889ef42e499dde432778881104a42f7071b/sentry_sdk-2.61.1-py3-none-any.whl", hash = "sha256:fa36eaf4b8ad708f718500d4bdcc1532637526a22beb874d88cbc0a46458b5ae", size = 483735, upload-time = "2026-06-01T07:24:17.027Z" }, ] [[package]] @@ -1609,75 +1695,70 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.48" +version = "2.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, - { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, - { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, - { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, - { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, - { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, - { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, - { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, - { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/a9/812a775bd8c1af0966d660238d005baf25e9bced1f038c8e71f00aa637a7/sqlalchemy-2.0.50-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2", size = 2161617, upload-time = "2026-05-24T20:00:00.761Z" }, + { url = "https://files.pythonhosted.org/packages/d5/74/5a6bc5496e9be8f740fbf80f9e6bd4ab965c8a80870eb07ab015e360957a/sqlalchemy-2.0.50-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f", size = 3244104, upload-time = "2026-05-24T20:07:38.158Z" }, + { url = "https://files.pythonhosted.org/packages/81/55/b260d8df2adc9bb0bf294f67b5f802ff0d84d99442b536b9efd0ea72d447/sqlalchemy-2.0.50-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21", size = 3243039, upload-time = "2026-05-24T20:14:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/58714005cbf370f16c3f30d30324a43be10069efcfe764f7236a2e851947/sqlalchemy-2.0.50-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39", size = 3195017, upload-time = "2026-05-24T20:07:40.086Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/67527fee039bd3e1a6ce3f03d2b62fd87ab9099c17052810d79496727b66/sqlalchemy-2.0.50-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b", size = 3215308, upload-time = "2026-05-24T20:14:26.034Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/dd3155a6a6706cb89adecf5ee6e0512f7b0ee5cf3e6f4cde67d3c20ebfda/sqlalchemy-2.0.50-cp310-cp310-win32.whl", hash = "sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031", size = 2121637, upload-time = "2026-05-24T20:08:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/93/a1/a09c463ee3e7764b5ce5bd19a7f0b6eefbde62e637439ab58498cdbd6b47/sqlalchemy-2.0.50-cp310-cp310-win_amd64.whl", hash = "sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc", size = 2144673, upload-time = "2026-05-24T20:08:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5d/3172686af1770e4de2805f919a51441085f589ddadf3dd76ec582f84f497/sqlalchemy-2.0.50-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", size = 2161366, upload-time = "2026-05-24T20:00:02.061Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/e98dedea3c3e663a17afcd003a34ba45efdac2cea3b6f2e4585e2b1e2537/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", size = 3318926, upload-time = "2026-05-24T20:07:42.369Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/501308c2babb62c11753ecb4ee88ba9eef019419a4d6cbf7cb13e2bad353/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", size = 3319199, upload-time = "2026-05-24T20:14:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/d88996c5e03ed6248c3a788d20f0b8d8b376b9f8a495e4bab9df7c72d2f8/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", size = 3270301, upload-time = "2026-05-24T20:07:44.917Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/1ae0e65161b51cc43e5ca75430ef79d80e23b5042d645586c2c342c3b92e/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", size = 3293465, upload-time = "2026-05-24T20:14:30.501Z" }, + { url = "https://files.pythonhosted.org/packages/83/29/17c0003f2c0dfa6d1b97672475707e3ec5980db09defd7fa20beb6833bbd/sqlalchemy-2.0.50-cp311-cp311-win32.whl", hash = "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", size = 2120694, upload-time = "2026-05-24T20:08:09.237Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/280d00654cc19d1fccf236fa5070f6dd04b84dde6f1b2e637bde0ff340a7/sqlalchemy-2.0.50-cp311-cp311-win_amd64.whl", hash = "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", size = 2145315, upload-time = "2026-05-24T20:08:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, ] [[package]] name = "starlette" -version = "1.0.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] @@ -1692,30 +1773,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/43/93828236ade737789fc5a85cf2cdc363c4e2694a2afff9e7bb9e7590214e/strip_hints-0.1.13-py3-none-any.whl", hash = "sha256:7ba1b07a193b1cc843fd87f21072202404c25dbc42d3222ac32b2bce4b196c8a", size = 23259, upload-time = "2025-02-21T01:33:18.175Z" }, ] +[[package]] +name = "tangle-api" +version = "0.1.0" +source = { editable = "packages/tangle-api" } +dependencies = [ + { name = "pydantic" }, + { name = "tangle-cli" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "tangle-cli", editable = "." }, +] + [[package]] name = "tangle-cli" -version = "0.0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, - { name = "cloud-pipelines-backend" }, { name = "cyclopts" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + +[package.optional-dependencies] +native = [ + { name = "tangle-api" }, ] [package.dev-dependencies] +codegen = [ + { name = "alembic" }, + { name = "bugsnag" }, + { name = "cloud-pipelines" }, + { name = "cloud-pipelines-backend" }, + { name = "fastapi", extra = ["standard"] }, + { name = "kubernetes" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-sdk" }, + { name = "sqlalchemy" }, +] dev = [ { name = "pytest" }, + { name = "tangle-api" }, ] [package.metadata] requires-dist = [ { name = "cloud-pipelines", specifier = ">=0.26.3.12" }, - { name = "cloud-pipelines-backend", git = "https://github.com/TangleML/tangle?rev=stable_cli" }, - { name = "cyclopts", specifier = ">=3.0" }, -] + { name = "cyclopts", specifier = ">=4.16.1" }, + { name = "docstring-parser", specifier = ">=0.16" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jinja2", specifier = ">=3.1" }, + { name = "platformdirs", specifier = ">=4.10.0" }, + { 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 = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, +] +provides-extras = ["native"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.0.2" }] +codegen = [ + { name = "alembic", specifier = ">=1.18.4" }, + { name = "bugsnag", specifier = ">=4.9.0,<5" }, + { name = "cloud-pipelines", specifier = ">=0.23.2.4" }, + { name = "cloud-pipelines-backend", git = "https://github.com/TangleML/tangle?rev=stable_cli" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "kubernetes", specifier = ">=33.1.0,<36" }, + { name = "opentelemetry-api", specifier = ">=1.41.1" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.41.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.60b1" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, + { name = "sqlalchemy", specifier = ">=2.0.49" }, +] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "tangle-api", editable = "packages/tangle-api" }, +] [[package]] name = "tomli" @@ -1773,17 +1922,17 @@ wheels = [ [[package]] name = "typer" -version = "0.24.1" +version = "0.26.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, ] [[package]] @@ -1809,25 +1958,25 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] [package.optional-dependencies] @@ -1887,105 +2036,131 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, - { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, - { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, - { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, - { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "webob" +version = "1.8.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "legacy-cgi", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f9/974eafebfd0bd442b8848899fe7d30675c93f750c313e1a6fe61acbde1e3/webob-1.8.10.tar.gz", hash = "sha256:1c963a11f307bc3f624fbab9dde737701eae255f32981b7a5486a88db1767c2b", size = 280796, upload-time = "2026-06-02T19:56:47.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/21/fce134877fb6fc6ad3c464e4a07ede0ee9219f705d26a981ae58ea36ca13/webob-1.8.10-py2.py3-none-any.whl", hash = "sha256:e68ad87fda378191081965ab02a185391c26e4e926adec855c3b0286a8369d49", size = 115825, upload-time = "2026-06-02T19:56:44.765Z" }, ] [[package]] @@ -2067,90 +2242,98 @@ wheels = [ [[package]] name = "wheel" -version = "0.46.3" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, ] [[package]] name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, -] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, ] diff --git a/uv.toml b/uv.toml new file mode 100644 index 0000000..5bdd9aa --- /dev/null +++ b/uv.toml @@ -0,0 +1,3 @@ +[[index]] +url = "https://pypi.org/simple" +default = true