From 7ab346a9d047ff9d63e76fd4f8836ccdaa08a378 Mon Sep 17 00:00:00 2001 From: Volv G <124614463+Volv-G@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:25:07 -0700 Subject: [PATCH 001/111] Initial commit --- .gitignore | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83972fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,218 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# 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, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml From 1f92a31b3ca7cae7997b6e5dd871230a50a269f2 Mon Sep 17 00:00:00 2001 From: Volv G <124614463+Volv-G@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:10:57 -0700 Subject: [PATCH 002/111] Add OpenAPI-backed API CLI and client Squash merge lab foundation and dynamic OpenAPI client. --- .github/CODEOWNERS | 2 + .gitignore | 67 +- LICENSE | 201 +++ README.md | 127 ++ pyproject.toml | 42 + tangle_cli/__init__.py | 0 tangle_cli/api_cli.py | 487 ++++++ tangle_cli/api_client.py | 296 ++++ tangle_cli/api_schema.py | 611 ++++++++ tangle_cli/api_transport.py | 291 ++++ tangle_cli/cli.py | 35 + tangle_cli/components_cli.py | 98 ++ tests/test_api_cli.py | 744 +++++++++ tests/test_api_client.py | 290 ++++ uv.lock | 2880 ++++++++++++++++++++++++++++++++++ uv.toml | 3 + 16 files changed, 6135 insertions(+), 39 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 tangle_cli/__init__.py create mode 100644 tangle_cli/api_cli.py create mode 100644 tangle_cli/api_client.py create mode 100644 tangle_cli/api_schema.py create mode 100644 tangle_cli/api_transport.py create mode 100644 tangle_cli/cli.py create mode 100644 tangle_cli/components_cli.py create mode 100644 tests/test_api_cli.py create mode 100644 tests/test_api_client.py create mode 100644 uv.lock create mode 100644 uv.toml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cf4c311 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Require Alexey Volkov and Volv Grebennikov to review changes in this lab repo. +* @Ark-kun @Volv-G diff --git a/.gitignore b/.gitignore index 83972fa..09be0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,8 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -92,34 +92,34 @@ ipython_config.py # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. -# Pipfile.lock +#Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. -# uv.lock +#uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml +#poetry.lock +#poetry.toml # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. # https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml +#pdm.lock +#pdm.toml .pdm-python .pdm-build/ # pixi # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock +#pixi.lock # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one # in the .venv directory. It is recommended not to include this directory in version control. .pixi @@ -131,19 +131,6 @@ __pypackages__/ celerybeat-schedule celerybeat.pid -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - # SageMath parsed files *.sage.py @@ -182,26 +169,24 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ # Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs .abstra/ # Visual Studio Code -# 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, -# you could uncomment the following to ignore the entire vscode folder +# 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, +# you could uncomment the following to ignore the entire vscode folder # .vscode/ -# Temporary file for partial code execution -tempCodeRunnerFile.py # Ruff stuff: .ruff_cache/ @@ -209,10 +194,14 @@ tempCodeRunnerFile.py # PyPI configuration file .pypirc +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + # Marimo marimo/_static/ marimo/_lsp/ __marimo__/ - -# Streamlit -.streamlit/secrets.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..659e72e --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# tangle-cli + +[WIP] Private experimental/lab CLI for Tangle, the open-source ML pipeline orchestration platform. + +This lab repo is used to iterate on the next CLI shape before promoting changes to the public OSS package. The CLI is built with [Cyclopts](https://cyclopts.readthedocs.io/) and exposes two top-level command groups: + +- `tangle api` for OpenAPI-driven commands generated from the Tangle FastAPI schema. +- `tangle sdk` for local SDK/scaffold commands such as component helpers. + +## Run locally + +```bash +uv run tangle --help +uv run tangle api --help +uv run tangle sdk --help +uv run tangle sdk components --help +``` + +## SDK commands + +SDK/scaffold commands live under `tangle sdk`. Component helpers are intentionally nested under `sdk`; root-level `tangle components ...` is not registered in this lab CLI. + +```bash +uv run tangle sdk components --help +uv run tangle sdk components annotations get +uv run tangle sdk components annotations set +``` + +## API commands + +API commands are generated from the FastAPI/OpenAPI schema exposed by the Tangle backend. `tangle api --help` stays usable on a cold cache and shows generated resource commands only when a cached schema is already available. Generated command invocations fetch the schema once on cache miss using the same `--base-url`, `--token`, `--auth-header`, and `--header` options. You can also refresh the local schema cache explicitly with: + +```bash +uv run tangle api refresh +``` + +By default the CLI fetches the schema from: + +```text +$TANGLE_API_URL/openapi.json +``` + +or, when `TANGLE_API_URL` is unset: + +```text +http://localhost:8000/openapi.json +``` + +You can also pass a base URL explicitly: + +```bash +uv run tangle api refresh --base-url http://localhost:8000 +``` + +Schemas are cached under the OS-specific user cache directory via `platformdirs`, with an `openapi` subdirectory. Common examples include: + +```text +macOS: ~/Library/Caches/tangle-cli/openapi/ +Linux: ~/.cache/tangle-cli/openapi/ +Windows: %LOCALAPPDATA%\\TangleML\\tangle-cli\\Cache\\openapi\\ +``` + +Override the OpenAPI schema cache directory with: + +```bash +export TANGLE_CLI_CACHE_DIR=/path/to/openapi-schema-cache +``` + +If your backend requires bearer auth, set a token: + +```bash +export TANGLE_API_TOKEN=... +``` + +or pass one per command: + +```bash +uv run tangle api refresh --token ... +``` + +For other `Authorization` schemes, use `--auth-header` or `TANGLE_API_AUTH_HEADER` (also accepts the reference-compatible `TANGLE_AUTH_HEADER`). Values can be either the raw authorization value or `Authorization: value`: + +```bash +export TANGLE_API_AUTH_HEADER='Basic ...' +uv run tangle api refresh --auth-header 'Bearer ...' +``` + +For arbitrary auth or routing headers, including `Cloud-Auth`, use `--header` (alias `-H`). `TANGLE_API_HEADERS` accepts a JSON object (or a newline-separated list of `Name: value` entries): + +```bash +export TANGLE_API_HEADERS='{"Cloud-Auth":"...","X-Api-Key":"..."}' +uv run tangle api refresh --header 'Cloud-Auth: ...' +uv run tangle api pipeline-runs list -H 'Cloud-Auth: ...' +``` + +Repeated `--header 'Name: value'` flags can be used with both `tangle api refresh` and generated API commands. Header values are sent to the backend but are not printed by the CLI. + +## Dynamic command examples + +After refreshing (or once a cached schema exists), OpenAPI resource paths become command groups. For example, `/api/pipeline_runs/` becomes `pipeline-runs`, `/api/components/{digest}` becomes `components`, and `/api/published_components/` becomes `published-components`: + +```bash +uv run tangle api pipeline-runs list +uv run tangle api pipeline-runs get RUN_ID +uv run tangle api pipeline-runs cancel RUN_ID +uv run tangle api components get DIGEST +uv run tangle api published-components list +uv run tangle api component-libraries get LIBRARY_ID +``` + +Path parameters are positional arguments and query parameters become options. Check generated help for the exact options exposed by your backend schema: + +```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 list --auth-header 'Bearer ...' +uv run tangle api pipeline-runs list --header 'Cloud-Auth: ...' +``` + +Simple JSON request body fields are exposed as options when possible. For complex bodies, pass JSON directly or read it from a file with `@file`: + +```bash +uv run tangle api pipeline-runs create --help +uv run tangle api pipeline-runs create --body @pipeline-run.json +``` + +Responses are printed as JSON when the backend returns JSON. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2001239 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "tangle-cli" +version = "0.0.1" +description = "CLI for Tangle, the open-source ML pipeline orchestration platform" +readme = "README.md" +authors = [ + { name = "Alexey Volkov", email = "alexey.volkov@ark-kun.com" }, + { name = "Tangle authors" }, +] +requires-python = ">=3.10" +dependencies = [ + "cloud-pipelines>=0.26.3.12", + "cloud-pipelines-backend", + "cyclopts>=4.16.1", + "httpx>=0.28.1", + "platformdirs>=4.10.0", +] + +[project.urls] +Homepage = "https://tangleml.com" +Documentation = "https://tangleml.com/docs/" +Repository = "https://github.com/TangleML/tangle-cli" +Issues = "https://github.com/TangleML/tangle-cli/issues" + +[project.scripts] +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 = "" + +[tool.uv.sources] +cloud-pipelines-backend = { git = "https://github.com/TangleML/tangle", rev = "stable_cli" } + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/tangle_cli/__init__.py b/tangle_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tangle_cli/api_cli.py b/tangle_cli/api_cli.py new file mode 100644 index 0000000..600cc40 --- /dev/null +++ b/tangle_cli/api_cli.py @@ -0,0 +1,487 @@ +"""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. 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 re +import sys +from typing import Annotated, Any + +import httpx +import platformdirs +from cyclopts import App, Parameter + +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_API_URL, + DEFAULT_TIMEOUT_SECONDS, + _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, +) + +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( + alias="-H", + help=( + "Custom request header as 'Name: value'. Repeat for multiple. " + "Applied after TANGLE_API_HEADERS." + ), + negative_iterable=(), + ), +] +BodyOption = Annotated[ + str | None, + Parameter(help="JSON request body, or @path/to/file.json."), +] + + +def build_app(schema: dict[str, Any] | None = None) -> App: + """Build the `tangle api` Cyclopts app. + + When *schema* is supplied, dynamic commands are generated from it. Otherwise + schema loading is driven by the current top-level CLI invocation: only actual + `tangle api ...` commands read the cache or fetch `/openapi.json`. + """ + + api_app = App( + name="api", + help="Call Tangle backend API endpoints from the cached OpenAPI schema.", + ) + _register_refresh_command(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.", + ) + groups[operation.group_name] = group + api_app.command(group) + + command = _make_operation_callable(operation) + group.command(command, name=operation.command_name) + + +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, + ) -> None: + """Fetch /openapi.json and update the local schema cache.""" + + normalized_base_url = _normalize_base_url(base_url) if base_url else default_base_url() + try: + schema, path = refresh_schema(normalized_base_url, token, header, auth_header) + 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 _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, + annotation=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 = ( + inspect.Parameter.empty + if parameter.required and not body_field_with_escape_hatch + else parameter.default + ) + 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( + "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.""" + + base_url = _normalize_base_url(values.pop("base_url", None) or default_base_url()) + token = values.pop("token", None) or 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, + ) + 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 dynamic commands for this process. + + We prefer the cache and only fetch on API-focused invocations. If the user + is dispatching a dynamic command, fetch failures are made actionable instead + of letting Cyclopts report that only the static `refresh` command exists. + """ + + api_tail = _api_argv_tail(sys.argv) + if api_tail is None: + return None + + base_url = _base_url_from_argv(api_tail) or default_base_url() + cached = load_cached_schema(base_url) + if cached is not None: + return cached + if not _argv_requests_api_schema(sys.argv): + return None + try: + return load_or_fetch_schema( + base_url, + _token_from_argv(api_tail), + _headers_from_argv(api_tail), + _auth_header_from_argv(api_tail), + ) + except Exception as exc: + if _argv_dispatches_dynamic_command(sys.argv): + raise SystemExit(_schema_fetch_failure_message(base_url, exc)) from exc + # Keep `tangle api --help` usable if the backend is unavailable; the + # explicit `refresh` command or an attempted dynamic command reports the + # concrete fetch failure and next step. + return None + + +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"} + + +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"} + + +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"} + 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_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: + return _option_from_argv(argv, "--base-url") or _option_from_argv(argv, "--api-url") + + +def _token_from_argv(argv: list[str]) -> str | None: + return _option_from_argv(argv, "--token") or default_token() + + +def _auth_header_from_argv(argv: list[str]) -> str | None: + return _option_from_argv(argv, "--auth-header") or default_auth_header() + + +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]) + return entries + + +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/tangle_cli/api_client.py b/tangle_cli/api_client.py new file mode 100644 index 0000000..795bc61 --- /dev/null +++ b/tangle_cli/api_client.py @@ -0,0 +1,296 @@ +"""Programmatic dynamic OpenAPI 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 TangleOpenApiClient: + """Dynamic 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, + ) -> TangleOpenApiClient: + """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, + ) -> TangleOpenApiClient: + """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 TangleOpenApiClient.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, + ) -> TangleOpenApiClient: + """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, + ) -> TangleOpenApiClient: + """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: TangleOpenApiClient, 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/tangle_cli/api_schema.py b/tangle_cli/api_schema.py new file mode 100644 index 0000000..9247e4b --- /dev/null +++ b/tangle_cli/api_schema.py @@ -0,0 +1,611 @@ +"""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, + default_token, +) + +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, +) -> 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 or default_token(), header, auth_header, headers), + 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, +) -> 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) + 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, +) -> 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) + 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/tangle_cli/api_transport.py b/tangle_cli/api_transport.py new file mode 100644 index 0000000..0a7696b --- /dev/null +++ b/tangle_cli/api_transport.py @@ -0,0 +1,291 @@ +"""HTTP transport helpers shared by the OpenAPI CLI and programmatic client.""" + +from __future__ import annotations + +import json +import os +import re +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() + + +def default_base_url() -> str: + return _normalize_base_url(os.environ.get("TANGLE_API_URL") or DEFAULT_API_URL) + + +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, +) -> 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"} + 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, +) -> 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, + ) + response = httpx.request( + method, + url, + content=content, + headers=request_headers, + timeout=timeout, + ) + 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, +) -> 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 = urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + 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) 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) + 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 _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) -> Any: + if isinstance(body, str): + return _load_body_argument(body) + 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/tangle_cli/cli.py b/tangle_cli/cli.py new file mode 100644 index 0000000..d2a59af --- /dev/null +++ b/tangle_cli/cli.py @@ -0,0 +1,35 @@ +from cyclopts import App + +from . import api_cli +from . import components_cli + + +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(components_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="0.0.1", + ) + 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/tangle_cli/components_cli.py b/tangle_cli/components_cli.py new file mode 100644 index 0000000..cdfba5c --- /dev/null +++ b/tangle_cli/components_cli.py @@ -0,0 +1,98 @@ +import pathlib +import sys + +from cyclopts import App + +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() + + +@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/test_api_cli.py b/tests/test_api_cli.py new file mode 100644 index 0000000..18eee0e --- /dev/null +++ b/tests/test_api_cli.py @@ -0,0 +1,744 @@ +import importlib +import json +import sys + +import httpx +import pytest + +from tangle_cli import api_cli, cli, 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 + + run_app(app, ["sdk", "components", "--help"]) + assert "Work with Tangle component definitions" 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_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_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_cache_miss_fetches_schema_before_dynamic_dispatch(monkeypatch, tmp_path): + 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, {"digest": "sha256:abc"}) + + 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", + "components", + "get", + "sha256:abc", + "--base-url", + "http://api.test", + "--auth-header", + "Basic cli-auth", + "-H", + "Cloud-Auth: cli-value", + ], + ) + + app = api_cli.build_app() + schema_headers = lower_headers(gets[0]["headers"]) + assert gets[0]["url"] == "http://api.test/openapi.json" + assert schema_headers["authorization"] == "Basic cli-auth" + assert schema_headers["cloud-auth"] == "cli-value" + assert gets[0]["timeout"] == api_cli.DEFAULT_TIMEOUT_SECONDS + + run_app( + app, + [ + "components", + "get", + "sha256:abc", + "--base-url", + "http://api.test", + "--auth-header", + "Basic cli-auth", + ], + ) + assert requests[-1]["url"] == "http://api.test/api/components/sha256%3Aabc" + + +def test_cold_cache_api_short_help_does_not_treat_help_as_dynamic_command( + 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", "-h"]) + + app = api_cli.build_app() + run_app(app, ["-h"]) + + output = capsys.readouterr().out + assert "refresh" in output + assert "Unknown command" not in output + + +def test_cache_miss_dynamic_fetch_failure_is_actionable(monkeypatch, tmp_path): + 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", + "components", + "get", + "sha256:abc", + "--auth-header", + "Basic secret-value", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + api_cli.build_app() + + message = str(exc_info.value) + assert "tangle api refresh" in message + assert "secret-value" not in message + + +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_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_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_client.py b/tests/test_api_client.py new file mode 100644 index 0000000..60309e9 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,290 @@ +import importlib +import json +import sys + +import httpx +import pytest + +from tangle_cli import api_schema +from tangle_cli.api_client import TangleOpenApiClient + + +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 = TangleOpenApiClient.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 = TangleOpenApiClient.from_cache_or_refresh( + base_url="https://api.test/", headers={"Cloud-Auth": "cloud-token"} + ) + second = TangleOpenApiClient.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 = TangleOpenApiClient.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 = TangleOpenApiClient.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 = TangleOpenApiClient.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_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 = TangleOpenApiClient.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 = TangleOpenApiClient.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.api_client as api_client + + importlib.reload(api_client) + + assert calls == [] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..212f605 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2880 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +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 = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +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/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.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.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +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/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]] +name = "cloud-pipelines" +version = "0.26.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "docstring-parser" }, + { name = "isodate" }, + { name = "pyyaml" }, + { name = "strip-hints" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/fb/856dc68694fe76722596ea5725feeade2aff5beba612c8e0d98f1597d94d/cloud_pipelines-0.26.3.12.tar.gz", hash = "sha256:d93b26567bcc2e7f740f55387e433aa09f33042e4188f0e00dfa0730c5ee24d4", size = 111439, upload-time = "2026-03-12T09:04:26.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/57/122ced86f5e7ff368cb27c572719329dfe91bada29bb1b38bf1ceb33322f/cloud_pipelines-0.26.3.12-py3-none-any.whl", hash = "sha256:e7b26d92857b96852b88c50a1b2354c5322ceeb2d56b12e49c020c52e2e5457a", size = 104660, upload-time = "2026-03-12T09:04:25.054Z" }, +] + +[[package]] +name = "cloud-pipelines-backend" +version = "0.1.0" +source = { git = "https://github.com/TangleML/tangle?rev=stable_cli#20fedcd7a18b7c3ec1f2f4eab8e7742f34276362" } +dependencies = [ + { name = "cloud-pipelines" }, + { 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" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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/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]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +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/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" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +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/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.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 = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +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/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.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.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +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]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +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/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/7a/c0a56c7d56c7fa723988f122fa1f1ccf8c5c4ccc48efad0d214b49e5b1af/isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9", size = 28443, upload-time = "2021-12-13T20:28:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/85/7882d311924cbcfc70b1890780763e36ff0b140c7e51c110fc59a532f087/isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", size = 41722, upload-time = "2021-12-13T20:28:29.073Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kubernetes" +version = "36.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/57/8b538af5076bc3372949d76f70ba3449bdfe52f9e6488170fa5d4f7cbe70/kubernetes-36.0.2.tar.gz", hash = "sha256:03551fcb49cae1f708f63624041e37403545b7aaed10cbf54e2b01a37a5438e3", size = 2336738, upload-time = "2026-06-01T18:20:30.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/5c160dbdef7123f8cc97fd8ece7e0198627a426a2a49614845e9086feb8d/kubernetes-36.0.2-py2.py3-none-any.whl", hash = "sha256:faf9b5241b58de0c4a5069f2a0ffc8ac06fece7215156cd3d3ba081a78a858b6", size = 4617568, upload-time = "2026-06-01T18:20:28.737Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +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/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]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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/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.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +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/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.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +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/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.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +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/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.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +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/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.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +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/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.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +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/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.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +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/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.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/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/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.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +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/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.63b1" +source = { registry = "https://pypi.org/simple" } +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/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.2" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +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/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] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +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/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/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]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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/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]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +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/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]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +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/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]] +name = "rich-rst" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/63/3e427c62f1992945c997d4ec31e2fcb37d26aadbe5aa44ae5b29f7f64d26/rich_toolkit-0.20.1.tar.gz", hash = "sha256:c7336ae281f435c785acecaedc4b71d4b663dc73d9c8079fea96372527e822a4", size = 203473, upload-time = "2026-06-05T08:56:57.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/88/309f07d08155da2ba1d5ceb42d270fb42fbe34a807684543e3ffc10fe713/rich_toolkit-0.20.1-py3-none-any.whl", hash = "sha256:2a6d5f8e15759b9eba5a9ee63da10b275359ead20e5a0fc92bd5b4dbae8ce4bf", size = 35525, upload-time = "2026-06-05T08:56:58.586Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/23faca29616d8966ada63fb0e13c214107811fa9a0aba2275e4c7ca63bd5/rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec", size = 824824, upload-time = "2025-11-05T20:42:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/05a1e61f04cf2548524224f0b5f21ca19ea58f7273a863bac10846b8ff69/rignore-0.7.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bde7c5835fa3905bfb7e329a4f1d7eccb676de63da7a3f934ddd5c06df20597", size = 899121, upload-time = "2025-11-05T20:40:48.94Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/71518847e10bdbf359badad8800e4681757a01f4777b3c5e03dbde8a42d8/rignore-0.7.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:626c3d4ba03af266694d25101bc1d8d16eda49c5feb86cedfec31c614fceca7d", size = 873813, upload-time = "2025-11-05T20:41:04.71Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c8/32ae405d3e7fd4d9f9b7838f2fcca0a5005bb87fa514b83f83fd81c0df22/rignore-0.7.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a43841e651e7a05a4274b9026cc408d1912e64016ede8cd4c145dae5d0635be", size = 1168019, upload-time = "2025-11-05T20:41:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/013c955982bc5b4719bf9a5bea58be317eea28aa12bfd004025e3cd7c000/rignore-0.7.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7978c498dbf7f74d30cdb8859fe612167d8247f0acd377ae85180e34490725da", size = 942822, upload-time = "2025-11-05T20:41:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/9a3f3156c6ed30bcd597e63690353edac1fcffe9d382ad517722b56ac195/rignore-0.7.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d22f72ab695c07d2d96d2a645208daff17084441b5d58c07378c9dd6f9c4c87", size = 959820, upload-time = "2025-11-05T20:42:06.364Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b2/93bf609633021e9658acaff24cfb055d8cdaf7f5855d10ebb35307900dda/rignore-0.7.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5bd8e1a91ed1a789b2cbe39eeea9204a6719d4f2cf443a9544b521a285a295f", size = 985050, upload-time = "2025-11-05T20:41:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/ec2d040469bdfd7b743df10f2201c5d285009a4263d506edbf7a06a090bb/rignore-0.7.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fc03efad5789365018e94ac4079f851a999bc154d1551c45179f7fcf45322", size = 1079164, upload-time = "2025-11-05T21:40:10.368Z" }, + { url = "https://files.pythonhosted.org/packages/df/26/4b635f4ea5baf4baa8ba8eee06163f6af6e76dfbe72deb57da34bb24b19d/rignore-0.7.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ce2617fe28c51367fd8abfd4eeea9e61664af63c17d4ea00353d8ef56dfb95fa", size = 1139028, upload-time = "2025-11-05T21:40:27.977Z" }, + { url = "https://files.pythonhosted.org/packages/6a/54/a3147ebd1e477b06eb24e2c2c56d951ae5faa9045b7b36d7892fec5080d9/rignore-0.7.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c4ad2cee85068408e7819a38243043214e2c3047e9bd4c506f8de01c302709e", size = 1119024, upload-time = "2025-11-05T21:40:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/27475db769a57cff18fe7e7267b36e6cdb5b1281caa185ba544171106cba/rignore-0.7.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:02cd240bfd59ecc3907766f4839cbba20530a2e470abca09eaa82225e4d946fb", size = 1128531, upload-time = "2025-11-05T21:41:02.734Z" }, + { url = "https://files.pythonhosted.org/packages/97/32/6e782d3b352e4349fa0e90bf75b13cb7f11d8908b36d9e2b262224b65d9a/rignore-0.7.6-cp310-cp310-win32.whl", hash = "sha256:fe2bd8fa1ff555259df54c376abc73855cb02628a474a40d51b358c3a1ddc55b", size = 646817, upload-time = "2025-11-05T21:41:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8a/53185c69abb3bb362e8a46b8089999f820bf15655629ff8395107633c8ab/rignore-0.7.6-cp310-cp310-win_amd64.whl", hash = "sha256:d80afd6071c78baf3765ec698841071b19e41c326f994cfa69b5a1df676f5d39", size = 727001, upload-time = "2025-11-05T21:41:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/b6e2be3069ef3b7f24e35d2911bd6deb83d20ed5642ad81d5a6d1c015473/rignore-0.7.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:40be8226e12d6653abbebaffaea2885f80374c1c8f76fe5ca9e0cadd120a272c", size = 885285, upload-time = "2025-11-05T20:42:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/ba7f561b6062402022887706a7f2b2c2e2e2a28f1e3839202b0a2f77e36d/rignore-0.7.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182f4e5e4064d947c756819446a7d4cdede8e756b8c81cf9e509683fe38778d7", size = 823882, upload-time = "2025-11-05T20:42:23.488Z" }, + { url = "https://files.pythonhosted.org/packages/f5/81/4087453df35a90b07370647b19017029324950c1b9137d54bf1f33843f17/rignore-0.7.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16b63047648a916a87be1e51bb5c009063f1b8b6f5afe4f04f875525507e63dc", size = 899362, upload-time = "2025-11-05T20:40:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c9/390a8fdfabb76d71416be773bd9f162977bd483084f68daf19da1dec88a6/rignore-0.7.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba5524f5178deca4d7695e936604ebc742acb8958f9395776e1fcb8133f8257a", size = 873633, upload-time = "2025-11-05T20:41:06.193Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/79404fcb0faa76edfbc9df0901f8ef18568d1104919ebbbad6d608c888d1/rignore-0.7.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62020dbb89a1dd4b84ab3d60547b3b2eb2723641d5fb198463643f71eaaed57d", size = 1167633, upload-time = "2025-11-05T20:41:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/b3466d32d445d158a0aceb80919085baaae495b1f540fb942f91d93b5e5b/rignore-0.7.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b34acd532769d5a6f153a52a98dcb81615c949ab11697ce26b2eb776af2e174d", size = 941434, upload-time = "2025-11-05T20:41:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/e8/40/9cd949761a7af5bc27022a939c91ff622d29c7a0b66d0c13a863097dde2d/rignore-0.7.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c5e53b752f9de44dff7b3be3c98455ce3bf88e69d6dc0cf4f213346c5e3416c", size = 959461, upload-time = "2025-11-05T20:42:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/b5/87/1e1a145731f73bdb7835e11f80da06f79a00d68b370d9a847de979575e6d/rignore-0.7.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25b3536d13a5d6409ce85f23936f044576eeebf7b6db1d078051b288410fc049", size = 985323, upload-time = "2025-11-05T20:41:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/1ecff992fc3f59c4fcdcb6c07d5f6c1e6dfb55ccda19c083aca9d86fa1c6/rignore-0.7.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e01cad2b0b92f6b1993f29fc01f23f2d78caf4bf93b11096d28e9d578eb08ce", size = 1079173, upload-time = "2025-11-05T21:40:12.007Z" }, + { url = "https://files.pythonhosted.org/packages/17/18/162eedadb4c2282fa4c521700dbf93c9b14b8842e8354f7d72b445b8d593/rignore-0.7.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5991e46ab9b4868334c9e372ab0892b0150f3f586ff2b1e314272caeb38aaedb", size = 1139012, upload-time = "2025-11-05T21:40:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/a9ca398a8af74bb143ad66c2a31303c894111977e28b0d0eab03867f1b43/rignore-0.7.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c8ae562e5d1246cba5eaeb92a47b2a279e7637102828dde41dcbe291f529a3e", size = 1118827, upload-time = "2025-11-05T21:40:46.6Z" }, + { url = "https://files.pythonhosted.org/packages/9f/22/1c1a65047df864def9a047dbb40bc0b580b8289a4280e62779cd61ae21f2/rignore-0.7.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaf938530dcc0b47c4cfa52807aa2e5bfd5ca6d57a621125fe293098692f6345", size = 1128182, upload-time = "2025-11-05T21:41:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f4/1526eb01fdc2235aca1fd9d0189bee4021d009a8dcb0161540238c24166e/rignore-0.7.6-cp311-cp311-win32.whl", hash = "sha256:166ebce373105dd485ec213a6a2695986346e60c94ff3d84eb532a237b24a4d5", size = 646547, upload-time = "2025-11-05T21:41:49.439Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/dda0983e1845706beb5826459781549a840fe5a7eb934abc523e8cd17814/rignore-0.7.6-cp311-cp311-win_amd64.whl", hash = "sha256:44f35ee844b1a8cea50d056e6a595190ce9d42d3cccf9f19d280ae5f3058973a", size = 727139, upload-time = "2025-11-05T21:41:34.367Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/eb1206b7bf65970d41190b879e1723fc6bbdb2d45e53565f28991a8d9d96/rignore-0.7.6-cp311-cp311-win_arm64.whl", hash = "sha256:14b58f3da4fa3d5c3fa865cab49821675371f5e979281c683e131ae29159a581", size = 657598, upload-time = "2025-11-05T21:41:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, + { url = "https://files.pythonhosted.org/packages/85/12/62d690b4644c330d7ac0f739b7f078190ab4308faa909a60842d0e4af5b2/rignore-0.7.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3d3a523af1cd4ed2c0cba8d277a32d329b0c96ef9901fb7ca45c8cfaccf31a5", size = 887462, upload-time = "2025-11-05T20:42:50.804Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/6528a0e97ed2bd7a7c329183367d1ffbc5b9762ae8348d88dae72cc9d1f5/rignore-0.7.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:990853566e65184a506e1e2af2d15045afad3ebaebb8859cb85b882081915110", size = 826918, upload-time = "2025-11-05T20:42:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/7d7bad116e09a04e9e1688c6f891fa2d4fd33f11b69ac0bd92419ddebeae/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cab9ff2e436ce7240d7ee301c8ef806ed77c1fd6b8a8239ff65f9bbbcb5b8a3", size = 900922, upload-time = "2025-11-05T20:41:00.361Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/e5ea89fbde8e37a90ce456e31c5e9d85512cef5ae38e0f4d2426eb776a19/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1a6671b2082c13bfd9a5cf4ce64670f832a6d41470556112c4ab0b6519b2fc4", size = 876987, upload-time = "2025-11-05T20:41:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fb/93d14193f0ec0c3d35b763f0a000e9780f63b2031f3d3756442c2152622d/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2468729b4c5295c199d084ab88a40afcb7c8b974276805105239c07855bbacee", size = 1171110, upload-time = "2025-11-05T20:41:32.631Z" }, + { url = "https://files.pythonhosted.org/packages/9e/46/08436312ff96ffa29cfa4e1a987efc37e094531db46ba5e9fda9bb792afd/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:775710777fd71e5fdf54df69cdc249996a1d6f447a2b5bfb86dbf033fddd9cf9", size = 943339, upload-time = "2025-11-05T20:41:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/34/28/3b3c51328f505cfaf7e53f408f78a1e955d561135d02f9cb0341ea99f69a/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4565407f4a77f72cf9d91469e75d15d375f755f0a01236bb8aaa176278cc7085", size = 961680, upload-time = "2025-11-05T20:42:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9e/cbff75c8676d4f4a90bd58a1581249d255c7305141b0868f0abc0324836b/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc44c33f8fb2d5c9da748de7a6e6653a78aa740655e7409895e94a247ffa97c8", size = 987045, upload-time = "2025-11-05T20:42:02.315Z" }, + { url = "https://files.pythonhosted.org/packages/8c/25/d802d1d369502a7ddb8816059e7c79d2d913e17df975b863418e0aca4d8a/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8f32478f05540513c11923e8838afab9efef0131d66dca7f67f0e1bbd118af6a", size = 1080310, upload-time = "2025-11-05T21:40:23.184Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/250b785c2e473b1ab763eaf2be820934c2a5409a722e94b279dddac21c7d/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:1b63a3dd76225ea35b01dd6596aa90b275b5d0f71d6dc28fce6dd295d98614aa", size = 1140998, upload-time = "2025-11-05T21:40:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d6/bb42fd2a8bba6aea327962656e20621fd495523259db40cfb4c5f760f05c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fe6c41175c36554a4ef0994cd1b4dbd6d73156fca779066456b781707402048e", size = 1121178, upload-time = "2025-11-05T21:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/97/f4/aeb548374129dce3dc191a4bb598c944d9ed663f467b9af830315d86059c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a0c6792406ae36f4e7664dc772da909451d46432ff8485774526232d4885063", size = 1130190, upload-time = "2025-11-05T21:41:16.403Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/a6250ff0c49a3cdb943910ada4116e708118e9b901c878cfae616c80a904/rignore-0.7.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a20b6fb61bcced9a83dfcca6599ad45182b06ba720cff7c8d891e5b78db5b65f", size = 886470, upload-time = "2025-11-05T20:42:52.314Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/c69c0c51b8f9f7914d95c4ea91c29a2ac067572048cae95dd6d2efdbe05d/rignore-0.7.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:392dcabfecbe176c9ebbcb40d85a5e86a5989559c4f988c2741da7daf1b5be25", size = 825976, upload-time = "2025-11-05T20:42:35.118Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d2/1b264f56132264ea609d3213ab603d6a27016b19559a1a1ede1a66a03dcd/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22baa462abdc36fdd5a5e2dae423107723351b85ff093762f9261148b9d0a04a", size = 899739, upload-time = "2025-11-05T20:41:01.518Z" }, + { url = "https://files.pythonhosted.org/packages/55/e4/b3c5dfdd8d8a10741dfe7199ef45d19a0e42d0c13aa377c83bd6caf65d90/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53fb28882d2538cb2d231972146c4927a9d9455e62b209f85d634408c4103538", size = 874843, upload-time = "2025-11-05T20:41:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/10/d6f3750233881a2a154cefc9a6a0a9b19da526b19f7f08221b552c6f827d/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87409f7eeb1103d6b77f3472a3a0d9a5953e3ae804a55080bdcb0120ee43995b", size = 1170348, upload-time = "2025-11-05T20:41:34.21Z" }, + { url = "https://files.pythonhosted.org/packages/6e/10/ad98ca05c9771c15af734cee18114a3c280914b6e34fde9ffea2e61e88aa/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684014e42e4341ab3ea23a203551857fcc03a7f8ae96ca3aefb824663f55db32", size = 942315, upload-time = "2025-11-05T20:41:48.508Z" }, + { url = "https://files.pythonhosted.org/packages/de/00/ab5c0f872acb60d534e687e629c17e0896c62da9b389c66d3aa16b817aa8/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77356ebb01ba13f8a425c3d30fcad40e57719c0e37670d022d560884a30e4767", size = 961047, upload-time = "2025-11-05T20:42:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/3030fdc363a8f0d1cd155b4c453d6db9bab47a24fcc64d03f61d9d78fe6a/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6cbd8a48abbd3747a6c830393cd578782fab5d43f4deea48c5f5e344b8fed2b0", size = 986090, upload-time = "2025-11-05T20:42:03.581Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/133aa4002cee0ebbb39362f94e4898eec7fbd09cec9fcbce1cd65b355b7f/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2673225dcec7f90497e79438c35e34638d0d0391ccea3cbb79bfb9adc0dc5bd7", size = 1079656, upload-time = "2025-11-05T21:40:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/67/56/36d5d34210e5e7dfcd134eed8335b19e80ae940ee758f493e4f2b344dd70/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c081f17290d8a2b96052b79207622aa635686ea39d502b976836384ede3d303c", size = 1139789, upload-time = "2025-11-05T21:40:42.119Z" }, + { url = "https://files.pythonhosted.org/packages/6b/5b/bb4f9420802bf73678033a4a55ab1bede36ce2e9b41fec5f966d83d932b3/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:57e8327aacc27f921968cb2a174f9e47b084ce9a7dd0122c8132d22358f6bd79", size = 1120308, upload-time = "2025-11-05T21:40:59.402Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/5d/a343201726150e05f2036eeb6e493e2e2f8bf8a66f5aa70f2f4ac96f9ca3/sentry_sdk-2.62.0.tar.gz", hash = "sha256:3c870b9f50d9fd15b58c817dbde1c7cfaa9fe3f05df0a4c6edd5571cb82f5491", size = 463986, upload-time = "2026-06-08T13:23:49.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/07/05440381627877aae223fd68f330df9b9fc6641d08bf65328b55235617a2/sentry_sdk-2.62.0-py3-none-any.whl", hash = "sha256:27f61d13a86c3c1648dec666dd5a64f79772dd6a84b446f11866601ecab24f6f", size = 490586, upload-time = "2026-06-08T13:23:47.486Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +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/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.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/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/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]] +name = "strip-hints" +version = "0.1.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/af/17ec9e52446330f60752687c7bc6328a68cb1aefe940f6d919a282b6a720/strip_hints-0.1.13.tar.gz", hash = "sha256:1feaebde61269ba0dea1e7f093df7ff9d4df4980cce7e3c22ed6d86ae43dbd06", size = 30332, upload-time = "2025-02-21T01:33:20.307Z" } +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-cli" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "cloud-pipelines" }, + { name = "cloud-pipelines-backend" }, + { name = "cyclopts" }, + { name = "httpx" }, + { name = "platformdirs" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[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 = ">=4.16.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "platformdirs", specifier = ">=4.10.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +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/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]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +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/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.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/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/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] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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 = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +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/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 = "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" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] 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 From e761ed876a29be6e6b2c5f8ead6b3f04e0716b50 Mon Sep 17 00:00:00 2001 From: Volv G <124614463+Volv-G@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:11:38 -0700 Subject: [PATCH 003/111] Add tangle-deploy compatibility surface Land tangle-deploy compatibility surface onto main. --- pyproject.toml | 1 + tangle_cli/__init__.py | 5 + tangle_cli/component_inspector.py | 524 ++++++++++++ tangle_cli/logger.py | 147 ++++ tangle_cli/models.py | 720 +++++++++++++++++ tangle_cli/py.typed | 0 tangle_cli/utils.py | 889 +++++++++++++++++++++ tests/test_component_inspector.py | 262 ++++++ tests/test_logger.py | 141 ++++ tests/test_models.py | 200 +++++ tests/test_tangle_deploy_compat_imports.py | 167 ++++ tests/test_utils.py | 179 +++++ uv.lock | 2 + 13 files changed, 3237 insertions(+) create mode 100644 tangle_cli/component_inspector.py create mode 100644 tangle_cli/logger.py create mode 100644 tangle_cli/models.py create mode 100644 tangle_cli/py.typed create mode 100644 tangle_cli/utils.py create mode 100644 tests/test_component_inspector.py create mode 100644 tests/test_logger.py create mode 100644 tests/test_models.py create mode 100644 tests/test_tangle_deploy_compat_imports.py create mode 100644 tests/test_utils.py diff --git a/pyproject.toml b/pyproject.toml index 2001239..c6ce4d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "cyclopts>=4.16.1", "httpx>=0.28.1", "platformdirs>=4.10.0", + "pyyaml>=6.0", ] [project.urls] diff --git a/tangle_cli/__init__.py b/tangle_cli/__init__.py index e69de29..79e4d72 100644 --- a/tangle_cli/__init__.py +++ b/tangle_cli/__init__.py @@ -0,0 +1,5 @@ +"""tangle-cli public API.""" + +from tangle_cli.api_client import TangleOpenApiClient + +__all__ = ["TangleOpenApiClient"] diff --git a/tangle_cli/component_inspector.py b/tangle_cli/component_inspector.py new file mode 100644 index 0000000..40c6f6a --- /dev/null +++ b/tangle_cli/component_inspector.py @@ -0,0 +1,524 @@ +"""Component inspection helpers for OpenAPI-backed Tangle clients.""" + +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import Any, Protocol +from urllib.parse import urljoin, urlparse +from weakref import WeakKeyDictionary + +import httpx +import yaml + +from tangle_cli.api_transport import _request_headers +from tangle_cli.models import ComponentInfo, ComponentSpec + +# ============================================================================ +# Client protocol helpers +# ============================================================================ + + +class ComponentApiClient(Protocol): + """Small subset of :class:`tangle_cli.api_client.TangleOpenApiClient` used here.""" + + base_url: str + + def call(self, operation_name: str, **params: Any) -> Any: ... + + +def _request_path(client: ComponentApiClient, path: str) -> httpx.Response: + """Fetch an API-origin path using a dynamic OpenAPI 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 dynamic client's base URL and auth/header precedence + without depending on the removed legacy hand-written client module. + """ + + custom_request_path = getattr(client, "request_path", None) + if callable(custom_request_path): + response = custom_request_path(path) + if hasattr(response, "raise_for_status"): + response.raise_for_status() + return response + + base_url = client.base_url.rstrip("/") + "/" + url = urljoin(base_url, path.lstrip("/")) + headers = _request_headers( + getattr(client, "token", None), + getattr(client, "header", None), + getattr(client, "auth_header", None), + getattr(client, "headers", None), + ) + response = httpx.request( + "GET", + url, + headers=headers, + timeout=getattr(client, "timeout", 30.0), + ) + response.raise_for_status() + return response + + +def _component_response(client: ComponentApiClient, digest: str) -> dict[str, Any] | None: + try: + data = client.call("components.get", digest=digest) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return None + raise + return data if isinstance(data, dict) else None + + +def _published_components( + client: ComponentApiClient, + *, + include_deprecated: bool = False, + name_substring: str | None = None, + published_by_substring: str | None = None, + digest: str | None = None, +) -> list[dict[str, Any]]: + result = client.call( + "published-components.list", + include_deprecated=include_deprecated, + name_substring=name_substring, + published_by_substring=published_by_substring, + digest=digest, + ) + if isinstance(result, dict) and isinstance(result.get("published_components"), list): + return result["published_components"] + if isinstance(result, list): + return result + return [] + + +def _resolve_digest(client: ComponentApiClient, 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.get("deprecated", False): + break + + superseded_by = meta.get("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: ComponentApiClient, 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: ComponentApiClient, 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: ComponentApiClient) -> 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: ComponentApiClient) -> _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 + + +def get_standard_library(client: ComponentApiClient) -> 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(client) + return { + "folders": [ + { + "name": folder.get("name"), + "components": [_strip_entry(comp) for comp in folder.get("components", [])], + } + for folder in library_full.get("folders", []) + ], + } + + +# ============================================================================ +# Core functions (usable by wrappers and CLIs) +# ============================================================================ + + +_TRANSPARENT_IMAGE_PREFIXES = ("python:", "ubuntu:", "debian:", "alpine:") + + +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" + + +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 _enrich_with_spec( + info: ComponentInfo, + client: ComponentApiClient, +) -> None: + """Fetch the full component data and attach it to *info*.""" + + if not info.digest: + return + try: + info.component_spec = _get_component_spec(client, info.digest) + except Exception as e: + info.spec_error = str(e) + + +def _get_component_spec(client: ComponentApiClient, digest: str) -> ComponentSpec | None: + data = _component_response(client, digest) + return ComponentSpec.from_dict(data) if data is not None else None + + +def inspect_by_digest( + client: ComponentApiClient, + digest: str, + full_spec: bool = False, + follow_deprecated: bool = False, +) -> dict[str, Any]: + """Inspect a single component by digest. + + Fetches the full spec via the ``components.get`` OpenAPI operation and + publication metadata via ``published-components.list``. + """ + + 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 = ComponentInfo.from_dict(pub_info) + else: + info = ComponentInfo(digest=digest) + info.version = comp.version if comp else None + + info.component_spec = comp + + result: dict[str, Any] = {"status": "success"} + if not pub_info: + result["published"] = False + if comp: + result["name"] = comp.name + transparent, transparency_reason = 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( + client: ComponentApiClient, + 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.""" + + published = _published_components( + client, + name_substring=name, + include_deprecated=include_deprecated, + published_by_substring=published_by, + ) + published = [ + c for c in published if c.get("name", "").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(c: dict[str, Any]) -> tuple[int, ...]: + """Parse version string into numeric tuple for proper sorting.""" + + v = c.get("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 pub in published: + info = ComponentInfo.from_dict(pub) + _enrich_with_spec(info, client) + entry = info.to_dict(strip_spec=not full_spec) + if info.component_spec: + transparent, transparency_reason = 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( + client: ComponentApiClient, + 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( + client, + name_substring=name, + include_deprecated=include_deprecated, + published_by_substring=published_by, + digest=digest, + ) + + results = [] + for comp in components: + results.append({ + "name": comp.get("name"), + "digest": comp.get("digest"), + "version": comp.get("version"), + "deprecated": comp.get("deprecated", False), + "description": (comp.get("description") or "")[:200], + }) + + return { + "status": "success", + "query": name, + "count": len(results), + "components": results, + } diff --git a/tangle_cli/logger.py b/tangle_cli/logger.py new file mode 100644 index 0000000..9a96645 --- /dev/null +++ b/tangle_cli/logger.py @@ -0,0 +1,147 @@ +"""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 +CliLogType = str # Valid values: "console", "none", "file" (Literal not supported by typer) + + +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). + """ + if log_type == "console": + result = fn(_default_logger) + _print_result(result) + return + + # "none" or "file" — capture logs, print result as JSON + capture = CaptureLogger() if log_type == "file" else None + logger: Logger = capture if capture is not None else _null_logger + + try: + result = fn(logger) + _print_result(result) + finally: + # Write captured logs to a temp file even if fn raised an exception + if capture is not 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) diff --git a/tangle_cli/models.py b/tangle_cli/models.py new file mode 100644 index 0000000..f61851a --- /dev/null +++ b/tangle_cli/models.py @@ -0,0 +1,720 @@ +""" +API-contract dataclasses for the Tangle (Oasis) Cloud Pipelines API. + +These dataclasses model the shapes of HTTP request/response bodies on the +Tangle API — ``PipelineRun``, ``ExecutionDetails``, ``ComponentSpec``, +etc. They are used by wrapper packages and OpenAPI-backed client helpers. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import yaml + +import tangle_cli.utils as utils + +# ---- Helpers --------------------------------------------------------------- + + +def _strip_text_from_graph(implementation: dict[str, Any]) -> None: + """Recursively remove ``text`` from ``componentRef`` objects in a graph implementation.""" + 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): + """ + Add the [Official] prefix to a component name if not already present. + + Args: + name: The original component name + + Returns: + The name with [Official] prefix + """ + if name and not name.startswith("[Official]"): + return f"[Official] {name}" + return name + + +# ---- 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() + + +@dataclass +class ExecutionDetails: + """Response from GET /api/executions/{id}/details.""" + id: str + task_spec: TaskSpec = field(default_factory=TaskSpec) + child_executions: dict[str, ExecutionDetails] = field(default_factory=dict) + pipeline_run_id: str | None = None + input_artifacts: dict[str, str] = field(default_factory=dict) + output_artifacts: dict[str, str] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ExecutionDetails: + return cls( + id=data.get("id", ""), + task_spec=TaskSpec.from_dict(data.get("task_spec", {})), + pipeline_run_id=data.get("pipeline_run_id"), + input_artifacts={k: v["id"] for k, v in data.get("input_artifacts", {}).items() if "id" in v}, + output_artifacts={k: v["id"] for k, v in data.get("output_artifacts", {}).items() if "id" in v}, + raw=data, + ) + + def strip_implementations(self) -> None: + """Remove implementation blocks from all component specs in-place.""" + self.task_spec.strip_implementations() + for child in self.child_executions.values(): + child.strip_implementations() + + @property + def tasks(self) -> dict[str, TaskSpec]: + """Shortcut to the root task_spec's graph_tasks.""" + return self.task_spec.graph_tasks + + +# ---- 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: ExecutionDetails | None = None + annotations: dict[str, str | None] | None = None + execution_state: GraphExecutionState | None = None + + +# ---- Artifacts ------------------------------------------------------------- + + +@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 with gs:// URI from GET /api/artifacts/{id}.""" + 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=ad.get("uri", ""), + key=key, + total_size=ad.get("total_size", 0), + is_dir=ad.get("is_dir", False), + hash=ad.get("hash"), + created_at=ad.get("created_at"), + ) + + +# ---- 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 ------------------------------------------------------------ + + +@dataclass +class ComponentSpec: + """Component specification extracted from YAML content or API response.""" + + data: dict = field(default_factory=dict) # The full parsed YAML data structure + version: str | None = None + name: str = "" + description: str | None = None + digest: str = "" + text: str | None = None + annotations: dict[str, str] = field(default_factory=dict) + inputs: list[dict[str, Any]] = field(default_factory=list) + outputs: list[dict[str, Any]] = field(default_factory=list) + implementation: dict[str, Any] | None = None + + # ---- factories ---- + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ComponentSpec: + """Create from a raw API response (``/api/components/{digest}``). + + Parses the ``text`` field into ``data`` when no ``spec`` key is present. + """ + spec = data.get("spec") + text = data.get("text") + if spec is None and text: + spec = yaml.safe_load(text) + spec = spec or {} + ann = spec.get("metadata", {}).get("annotations", {}) + return cls( + digest=data.get("digest", ""), + data=spec, + text=text, + name=spec.get("name", ""), + version=ann.get("version"), + description=spec.get("description"), + annotations=ann, + inputs=spec.get("inputs", []), + outputs=spec.get("outputs", []), + implementation=spec.get("implementation"), + ) + + @staticmethod + def from_yaml_file(yaml_path: str) -> ComponentSpec: + """Load and extract component specification from a YAML file. + + Raises: + FileNotFoundError: If the file doesn't exist + ValueError: If component name is missing or file is empty + """ + with open(yaml_path) as f: + yaml_content = f.read() + return ComponentSpec.from_yaml(yaml_content) + + @staticmethod + def from_yaml( + yaml_content: str, + annotations: dict[str, str] | None = None, + ) -> ComponentSpec: + """Extract component specification from YAML content. + + Args: + yaml_content: YAML string content + annotations: Optional annotations to add before extracting version + + Raises: + ValueError: If name is not found in YAML + """ + data = utils.parse_yaml_string(yaml_content) + + if not data: + raise ValueError("Unable to parse YAML content") + + # Apply custom annotations before extracting version + if annotations: + if 'metadata' not in data: + data['metadata'] = {} + if 'annotations' not in data['metadata']: + data['metadata']['annotations'] = {} + data['metadata']['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) + if not version: + version = None + + return ComponentSpec( + 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'), + ) + + @classmethod + def from_spec(cls, spec: dict[str, Any]) -> ComponentSpec: + """Create from an inline component spec dict (e.g. from an execution task_spec).""" + ann = spec.get("metadata", {}).get("annotations", {}) + return cls( + data=spec, + name=spec.get("name", ""), + description=spec.get("description"), + annotations=ann, + inputs=spec.get("inputs", []), + outputs=spec.get("outputs", []), + implementation=spec.get("implementation"), + ) + + def __bool__(self) -> bool: + return bool(self.data) + + # ---- properties ---- + + @property + def search_names(self) -> list[str]: + """Get names to use for searching (both original and with [Official] prefix).""" + return [self.name, add_official_prefix(self.name)] + + _STRIP_ANNOTATION_KEYS = {"python_original_code", "python_dependencies"} + + def strip_implementation(self, *, keep_graph: bool = False) -> None: + """Remove implementation details in-place. + + Args: + keep_graph: If True, preserve the graph structure but strip + ``text`` from nested ``componentRef`` objects. If False + (default), remove the implementation block entirely. + """ + self.text = None + if keep_graph: + if self.implementation: + _strip_text_from_graph(self.implementation) + else: + self.implementation = None + self.data.pop("implementation", None) + + @property + def stripped_spec(self) -> dict[str, Any] | None: + """Data with bulky annotations and implementation blocks removed. + + Returns a shallow copy — does not mutate ``self``. + """ + if not self.data: + return None + result = dict(self.data) + result.pop("implementation", None) + annotations = result.get("metadata", {}).get("annotations", {}) + if annotations: + result["metadata"] = dict(result["metadata"]) + result["metadata"]["annotations"] = { + k: v for k, v in annotations.items() if k not in self._STRIP_ANNOTATION_KEYS + } + return result + + # ---- serialisation ---- + + def to_yaml(self) -> str: + """Convert the component data back to YAML string.""" + return utils.dump_yaml(self.data) + + def save_to_file(self, file_path: str) -> None: + """Save the component spec to a YAML file.""" + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w') as f: + f.write(self.to_yaml()) + + # ---- mutation helpers ---- + + def update_fields( + self, + git_remote_sha=None, + git_remote_branch=None, + git_remote_url=None, + image=None, + component_yaml_path=None, + ): + """Update component spec with publishing metadata (in-place). + + Returns self for method chaining. + """ + from datetime import datetime, timezone + + if 'metadata' not in self.data: + self.data['metadata'] = {} + if 'annotations' not in self.data['metadata']: + self.data['metadata']['annotations'] = {} + + self.data['metadata']['annotations']['published_at'] = datetime.now(timezone.utc).isoformat() + + annotations = self.data['metadata']['annotations'] + 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: + version = self.data.pop('version') + self.data['metadata']['annotations']['version'] = str(version) + if 'updated_at' in self.data: + updated_at = self.data.pop('updated_at') + self.data['metadata']['annotations']['updated_at'] = str(updated_at) + + if image: + if 'implementation' not in self.data: + self.data['implementation'] = {} + if 'container' not in self.data['implementation']: + self.data['implementation']['container'] = {} + self.data['implementation']['container']['image'] = image + + return self + + def fetch_from_url(self, url: str, timeout: int = 10) -> bool: + """Fetch spec from a URL, populating ``data`` and ``text``. + + Returns True if the fetch succeeded. + """ + 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", "") + return True + except Exception: + return False + + def ensure_digest(self) -> str | None: + """Compute and set ``digest`` if missing. Returns the digest. + + Resolution order: existing digest > hash of raw text > hash of data. + """ + if self.digest: + return self.digest + from tangle_cli.utils import compute_spec_digest, compute_text_digest + + if self.text: + self.digest = compute_text_digest(self.text) + elif self.data: + self.digest = compute_spec_digest(self.data) + return self.digest or None + + +@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 diff --git a/tangle_cli/py.typed b/tangle_cli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tangle_cli/utils.py b/tangle_cli/utils.py new file mode 100644 index 0000000..30e9abf --- /dev/null +++ b/tangle_cli/utils.py @@ -0,0 +1,889 @@ +""" +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 +from pathlib import Path +from typing import Any + +import yaml + +from tangle_cli.logger import Logger, get_default_logger + +# ============================================================================= +# 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/tests/test_component_inspector.py b/tests/test_component_inspector.py new file mode 100644 index 0000000..7b25975 --- /dev/null +++ b/tests/test_component_inspector.py @@ -0,0 +1,262 @@ +"""Tests for generic OpenAPI-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 ( + get_standard_library, + inspect_by_digest, + inspect_by_name, + search_components, + transparency_check, +) +from tangle_cli.models import 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 call(self, operation_name: str, **params: Any) -> Any: + if operation_name == "components.get": + return self.component if params.get("digest") == "abc123" else None + if operation_name == "published-components.list": + if params.get("digest") == "abc123" or params.get("name_substring") == "demo": + return { + "published_components": [ + { + "name": "demo", + "digest": "abc123", + "version": "1.2.3", + "published_by": "user@example.com", + "description": "Demo component", + } + ] + } + if params.get("digest") == "old": + return { + "published_components": [ + { + "name": "demo", + "digest": "old", + "version": "1.0.0", + "deprecated": True, + "superseded_by": "abc123", + } + ] + } + return {"published_components": []} + raise AssertionError(f"unexpected operation: {operation_name}") + + +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 = 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 = 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 call(self, operation_name: str, **params: Any) -> Any: + raise AssertionError(f"unexpected operation: {operation_name}") + + 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 = get_standard_library(client) + + 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 call(self, operation_name: str, **params: Any) -> Any: + raise AssertionError(f"unexpected operation: {operation_name}") + + 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 = get_standard_library(client) + + 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 call(self, operation_name: str, **params: Any) -> Any: + if operation_name in {"components.get", "published-components.list"}: + return {"published_components": []} + raise AssertionError(f"unexpected operation: {operation_name}") + + 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 = inspect_by_name(first_client, "private-a") + second_result = inspect_by_name(second_client, "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 = inspect_by_digest(FakeClient(), "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 = inspect_by_digest(FakeClient(), "old", follow_deprecated=True) + + assert result["status"] == "success" + assert result["digest"] == "abc123" + + def test_inspect_by_name_returns_matching_versions(self): + result = inspect_by_name(FakeClient(), "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 = search_components(FakeClient(), 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 call(self, operation_name: str, **params: Any) -> Any: + if operation_name == "published-components.list": + return {"published_components": [{"name": "demo", "digest": "abc123", "description": None}]} + return super().call(operation_name, **params) + + result = search_components(NullDescriptionClient(), name="demo") + + assert result["components"][0]["description"] == "" 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..68de9a2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,200 @@ +"""Round-trip tests for the API-contract dataclasses in :mod:`tangle_cli.models`.""" + +from __future__ import annotations + +from tangle_cli.models import ( + ComponentInfo, + ComponentSpec, + ContainerState, + ExecutionDetails, + GraphExecutionState, + PipelineRun, + SecretInfo, + UserInfo, + 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_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_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 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 TestExecutionDetails: + def test_from_dict_parses_artifacts(self): + ed = ExecutionDetails.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"} diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py new file mode 100644 index 0000000..d492940 --- /dev/null +++ b/tests/test_tangle_deploy_compat_imports.py @@ -0,0 +1,167 @@ +"""Import guards for the compatibility surface tangle-deploy consumes.""" + +from __future__ import annotations + +import importlib.util + + +def test_tangle_deploy_required_import_surface_uses_openapi_client_only() -> None: + import tangle_cli + from tangle_cli import TangleOpenApiClient, utils as utils_module + from tangle_cli.api_client import TangleOpenApiClient as OpenApiClient + from tangle_cli.component_inspector import ( + get_standard_library, + inspect_by_digest, + inspect_by_name, + search_components, + transparency_check, + ) + 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, + ExecutionDetails, + GraphExecutionState, + KubernetesDebugInfo, + KubernetesJobInfo, + PageChunk, + PipelineRun, + RunDetails, + SecretInfo, + TaskSpec, + UserInfo, + _strip_text_from_graph, + add_official_prefix, + ) + from tangle_cli.utils import ( + _CI_BRANCH_VARS, + _CI_GIT_ROOT_VARS, + _CI_REPO_URL_VARS, + _CI_SHA_VARS, + OrderedDict, + TaskProcessor, + 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 TangleOpenApiClient.__name__ == OpenApiClient.__name__ == "TangleOpenApiClient" + old_client_name = "Tangle" + "ApiClient" + old_client_module = "tangle_cli." + "client" + assert not hasattr(tangle_cli, old_client_name) + assert importlib.util.find_spec(old_client_module) is None + assert ComponentSpec.__name__ == "ComponentSpec" + assert PipelineRun.__name__ == "PipelineRun" + assert callable(get_standard_library) + assert callable(inspect_by_digest) + assert callable(inspect_by_name) + assert callable(search_components) + assert callable(transparency_check) + 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 ExecutionDetails 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_tangle_deploy_shopify_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 = ("SHOPIFY_BUILD_COMMIT", *u._CI_SHA_VARS) + u._CI_BRANCH_VARS = ("SHOPIFY_BUILD_BRANCH", *u._CI_BRANCH_VARS) + u._CI_REPO_URL_VARS = ("SHOPIFY_BUILD_REPO", *u._CI_REPO_URL_VARS) + + assert u._CI_GIT_ROOT_VARS[0] == "APPLICATION_ROOT" + assert u._CI_SHA_VARS[0] == "SHOPIFY_BUILD_COMMIT" + assert u._CI_BRANCH_VARS[0] == "SHOPIFY_BUILD_BRANCH" + assert u._CI_REPO_URL_VARS[0] == "SHOPIFY_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/uv.lock b/uv.lock index 212f605..813fcf7 100644 --- a/uv.lock +++ b/uv.lock @@ -2284,6 +2284,7 @@ dependencies = [ { name = "cyclopts" }, { name = "httpx" }, { name = "platformdirs" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -2298,6 +2299,7 @@ requires-dist = [ { name = "cyclopts", specifier = ">=4.16.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] From 4621be6d8a6e22b8abdc45c66de785c61984f321 Mon Sep 17 00:00:00 2001 From: Volv G <124614463+Volv-G@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:44:51 -0700 Subject: [PATCH 004/111] feat: add published component sdk commands Keep OpenAPI @file body expansion CLI-only and add sdk published-components commands for registry inspection. --- README.md | 10 +- tangle_cli/api_cli.py | 1 + tangle_cli/api_transport.py | 22 +++- tangle_cli/cli.py | 2 + tangle_cli/published_components_cli.py | 165 +++++++++++++++++++++++++ tests/test_api_cli.py | 165 ++++++++++++++++++++++++- tests/test_api_client.py | 17 +++ 7 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 tangle_cli/published_components_cli.py diff --git a/README.md b/README.md index 659e72e..a36792f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This lab repo is used to iterate on the next CLI shape before promoting changes to the public OSS package. The CLI is built with [Cyclopts](https://cyclopts.readthedocs.io/) and exposes two top-level command groups: - `tangle api` for OpenAPI-driven commands generated from the Tangle FastAPI schema. -- `tangle sdk` for local SDK/scaffold commands such as component helpers. +- `tangle sdk` for local SDK/scaffold commands and published-component inspection helpers. ## Run locally @@ -14,16 +14,22 @@ uv run tangle --help uv run tangle api --help uv run tangle sdk --help uv run tangle sdk components --help +uv run tangle sdk published-components --help ``` ## SDK commands -SDK/scaffold commands live under `tangle sdk`. Component helpers are intentionally nested under `sdk`; root-level `tangle components ...` is not registered in this lab CLI. +SDK/scaffold commands live under `tangle sdk`. Local component generation/spec helpers are intentionally nested under `sdk components`; root-level `tangle components ...` is not registered in this lab CLI. Published/registry component inspection lives separately under `sdk published-components` so local component authoring and published component lookup do not share the same command group. ```bash uv run tangle sdk components --help uv run tangle sdk components annotations get uv run tangle sdk components annotations set +uv run tangle sdk published-components --help +uv run tangle sdk published-components search transformer +uv run tangle sdk published-components inspect transformer +uv run tangle sdk published-components inspect --digest sha256:... +uv run tangle sdk published-components library ``` ## API commands diff --git a/tangle_cli/api_cli.py b/tangle_cli/api_cli.py index 600cc40..9015292 100644 --- a/tangle_cli/api_cli.py +++ b/tangle_cli/api_cli.py @@ -330,6 +330,7 @@ def _invoke_operation(operation: OperationCommand, values: dict[str, Any]) -> No header_entries=header_entries, body=body_arg, timeout=DEFAULT_TIMEOUT_SECONDS, + allow_body_file_references=True, ) except httpx.HTTPStatusError as exc: message = exc.response.text or exc.response.reason_phrase diff --git a/tangle_cli/api_transport.py b/tangle_cli/api_transport.py index 0a7696b..5d9e0f6 100644 --- a/tangle_cli/api_transport.py +++ b/tangle_cli/api_transport.py @@ -151,6 +151,7 @@ def request_operation( headers: dict[str, str] | None = None, body: Any = _MISSING, timeout: float = DEFAULT_TIMEOUT_SECONDS, + allow_body_file_references: bool = False, ) -> httpx.Response: """Dispatch one normalized OpenAPI operation as an HTTP request. @@ -168,6 +169,7 @@ def request_operation( header_entries=header_entries, headers=headers, body=body, + allow_body_file_references=allow_body_file_references, ) response = httpx.request( method, @@ -190,6 +192,7 @@ def build_operation_request( header_entries: list[str] | str | None = None, headers: dict[str, str] | None = None, body: Any = _MISSING, + allow_body_file_references: bool = False, ) -> tuple[str, str, dict[str, str], bytes | None]: """Build method, URL, headers, and body bytes for an operation.""" @@ -237,7 +240,13 @@ def build_operation_request( if operation.has_request_body: if body is _MISSING: body = None - request_body = _coerce_body_argument(body) if body is not None else 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 = {} @@ -275,10 +284,15 @@ def _load_body_argument(body: str) -> Any: raise SystemExit(f"Invalid JSON body: {exc}") from exc -def _coerce_body_argument(body: Any) -> Any: - if isinstance(body, str): +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) - return body + try: + return json.loads(body) + except json.JSONDecodeError: + return body def _body_to_content(request_body: Any) -> bytes | None: diff --git a/tangle_cli/cli.py b/tangle_cli/cli.py index d2a59af..2af7ccb 100644 --- a/tangle_cli/cli.py +++ b/tangle_cli/cli.py @@ -2,6 +2,7 @@ from . import api_cli from . import components_cli +from . import published_components_cli def build_sdk_app() -> App: @@ -12,6 +13,7 @@ def build_sdk_app() -> App: help="Work with local Tangle SDK resources and scaffolding.", ) sdk_app.command(components_cli.app) + sdk_app.command(published_components_cli.app) return sdk_app diff --git a/tangle_cli/published_components_cli.py b/tangle_cli/published_components_cli.py new file mode 100644 index 0000000..b955c0c --- /dev/null +++ b/tangle_cli/published_components_cli.py @@ -0,0 +1,165 @@ +"""`tangle sdk published-components` command implementation.""" + +from __future__ import annotations + +import json +from typing import Annotated + +from cyclopts import App, Parameter + +from .api_client import TangleOpenApiClient +from .api_transport import DEFAULT_TIMEOUT_SECONDS +from .component_inspector import ( + get_standard_library, + inspect_by_digest, + inspect_by_name, + search_components, +) + +BaseUrlOption = Annotated[ + str | None, + Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), +] +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( + alias="-H", + help="Custom request header as 'Name: value'. Repeat for multiple.", + negative_iterable=(), + ), +] + +app = App( + name="published-components", + help="Inspect and search published Tangle components from the registry.", +) + + +def _client_from_options( + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, +) -> TangleOpenApiClient: + """Create the dynamic OpenAPI client used by published-component commands.""" + + return TangleOpenApiClient.from_cache_or_refresh( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + +def _print_json(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command(name="search") +def published_components_search( + name: str | None = None, + *, + include_deprecated: bool = False, + published_by: str | None = None, + digest: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, +) -> None: + """Search published component metadata.""" + + client = _client_from_options( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ) + _print_json( + search_components( + client, + name=name, + include_deprecated=include_deprecated, + published_by=published_by, + digest=digest, + ) + ) + + +@app.command(name="inspect") +def published_components_inspect( + name: str | None = None, + *, + digest: str | None = None, + all_versions: bool = False, + include_deprecated: bool = False, + follow_deprecated: bool = False, + full_spec: bool = False, + published_by: str | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, +) -> None: + """Inspect a published component by exact name or digest.""" + + if bool(name) == bool(digest): + raise SystemExit("Provide exactly one of NAME or --digest DIGEST") + + client = _client_from_options( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ) + if digest: + result = inspect_by_digest( + client, + digest, + full_spec=full_spec, + follow_deprecated=follow_deprecated, + ) + else: + result = inspect_by_name( + client, + name or "", + include_all_versions=all_versions, + include_deprecated=include_deprecated, + full_spec=full_spec, + published_by=published_by, + ) + _print_json(result) + + +@app.command(name="library") +def published_components_library( + *, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, +) -> None: + """Print the curated standard component library.""" + + client = _client_from_options( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ) + _print_json(get_standard_library(client)) diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 18eee0e..07094d2 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -5,7 +5,7 @@ import httpx import pytest -from tangle_cli import api_cli, cli, components_cli +from tangle_cli import api_cli, cli, components_cli, published_components_cli SCHEMA = { @@ -250,10 +250,14 @@ def test_root_app_exposes_api_and_sdk_groups(capsys): 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 @@ -279,6 +283,138 @@ def test_sdk_component_annotation_commands_preserve_help_and_error_behavior(caps 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, + ) + monkeypatch.setattr( + published_components_cli, + "search_components", + lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, + ) + monkeypatch.setattr( + published_components_cli, + "inspect_by_name", + lambda client, name, **kwargs: { + "client_ok": client is fake_client, + "name": name, + "inspect": kwargs, + }, + ) + monkeypatch.setattr( + published_components_cli, + "inspect_by_digest", + lambda client, digest, **kwargs: { + "client_ok": client is fake_client, + "digest": digest, + "inspect": kwargs, + }, + ) + monkeypatch.setattr( + published_components_cli, + "get_standard_library", + lambda client: {"client_ok": 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_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 = [] @@ -460,6 +596,33 @@ def fake_request(method, url, **kwargs): ) +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 = [] diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 60309e9..866eab7 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -220,6 +220,23 @@ def fake_request(method, url, **kwargs): } +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 = TangleOpenApiClient.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 = [] From d7acc2eb09d33c6a1b3fa68461a8cf7c03fbe217 Mon Sep 17 00:00:00 2001 From: Volv G Date: Wed, 10 Jun 2026 18:53:19 -0700 Subject: [PATCH 005/111] feat: add static generated API client --- .gitmodules | 4 + README.md | 122 +- pyproject.toml | 20 + tangle_cli/__init__.py | 3 +- tangle_cli/api_cli.py | 168 +- tangle_cli/client.py | 572 +++ tangle_cli/generated/__init__.py | 1 + tangle_cli/generated/models.py | 325 ++ tangle_cli/generated/operations.py | 376 ++ tangle_cli/models.py | 44 +- tangle_cli/openapi/__init__.py | 0 tangle_cli/openapi/codegen.py | 471 +++ tangle_cli/openapi/openapi.json | 3881 ++++++++++++++++++++ tangle_cli/openapi/parser.py | 34 + tests/test_api_cli.py | 280 +- tests/test_codegen.py | 227 ++ tests/test_static_client.py | 104 + tests/test_tangle_deploy_compat_imports.py | 13 +- third_party/tangle | 1 + uv.lock | 989 +---- 20 files changed, 6766 insertions(+), 869 deletions(-) create mode 100644 .gitmodules create mode 100644 tangle_cli/client.py create mode 100644 tangle_cli/generated/__init__.py create mode 100644 tangle_cli/generated/models.py create mode 100644 tangle_cli/generated/operations.py create mode 100644 tangle_cli/openapi/__init__.py create mode 100644 tangle_cli/openapi/codegen.py create mode 100644 tangle_cli/openapi/openapi.json create mode 100644 tangle_cli/openapi/parser.py create mode 100644 tests/test_codegen.py create mode 100644 tests/test_static_client.py create mode 160000 third_party/tangle 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 a36792f..77034fd 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,15 @@ uv run tangle sdk published-components library ## API commands -API commands are generated from the FastAPI/OpenAPI schema exposed by the Tangle backend. `tangle api --help` stays usable on a cold cache and shows generated resource commands only when a cached schema is already available. Generated command invocations fetch the schema once on cache miss using the same `--base-url`, `--token`, `--auth-header`, and `--header` options. You can also refresh the local schema cache explicitly with: +API commands are pre-generated from the checked-in official Tangle FastAPI/OpenAPI snapshot, so `tangle api --help` shows resource command groups immediately on a cold cache and command invocations do not require `refresh` first. By default, the CLI uses `--schema-source auto`: official static operations are always present, and cached live-backend operations discovered by `tangle api refresh` are included as extensions when they exist. Cached schemas do not override official operations with the same method/path; official definitions win. + +You can refresh the local schema cache explicitly for a live backend with: ```bash uv run tangle api refresh ``` -By default the CLI fetches the schema from: +When refreshing, the CLI fetches the schema from: ```text $TANGLE_API_URL/openapi.json @@ -52,12 +54,23 @@ or, when `TANGLE_API_URL` is unset: http://localhost:8000/openapi.json ``` -You can also pass a base URL explicitly: +You can also pass a base URL explicitly. This caches non-official/live backend schemas such as Oasis for automatic extension discovery: ```bash uv run tangle api refresh --base-url http://localhost:8000 +uv run tangle api refresh --base-url https://oasis.shopify.io ``` +To delete the live/dynamic schema cache for a base URL without touching the +checked-in official snapshot, run: + +```bash +uv run tangle api reset-cache --base-url https://oasis.shopify.io +``` + +When `--base-url` is omitted, `reset-cache` uses the same base URL resolution as +`refresh`: `TANGLE_API_URL`, then the default local API URL. + Schemas are cached under the OS-specific user cache directory via `platformdirs`, with an `openapi` subdirectory. Common examples include: ```text @@ -101,9 +114,32 @@ uv run tangle api pipeline-runs list -H 'Cloud-Auth: ...' Repeated `--header 'Name: value'` flags can be used with both `tangle api refresh` and generated API commands. Header values are sent to the backend but are not printed by the CLI. -## Dynamic command examples +Schema source modes are: + +- `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. +- `--schema-source official`: only the checked-in official static schema (OSS-only/core commands). +- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. -After refreshing (or once a cached schema exists), OpenAPI resource paths become command groups. For example, `/api/pipeline_runs/` becomes `pipeline-runs`, `/api/components/{digest}` becomes `components`, and `/api/published_components/` becomes `published-components`: +For resource help, put the option 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://oasis.shopify.io --body @query.json +``` + +Omit `--schema-source` to use `auto`, which includes cached-only backend +extensions after refresh while preserving official definitions for core +operations. Use `--schema-source official` to force OSS-only/core commands. + +## Static and dynamic command examples + +OpenAPI resource paths are available as command groups from the checked-in official schema, with cached-only backend extensions included in auto mode after refresh. For example, `/api/pipeline_runs/` becomes `pipeline-runs`, `/api/components/{digest}` becomes `components`, and `/api/published_components/` becomes `published-components`: ```bash uv run tangle api pipeline-runs list @@ -114,7 +150,7 @@ uv run tangle api published-components list uv run tangle api component-libraries get LIBRARY_ID ``` -Path parameters are positional arguments and query parameters become options. Check generated help for the exact options exposed by your backend schema: +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 @@ -131,3 +167,77 @@ uv run tangle api pipeline-runs create --body @pipeline-run.json ``` Responses are printed as JSON when the backend returns JSON. + +## 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.get_pipeline_run("run-id") +``` + +`TangleApiClient` uses checked-in endpoint methods generated offline from +`tangle_cli/openapi/openapi.json`, so normal imports do not fetch or parse the +OpenAPI schema. + +To refresh the checked-in generated methods/models from the official Tangle +backend submodule, run: + +```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 `tangle_cli/openapi/openapi.json`, and +regenerates `tangle_cli/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. If the submodule is missing, initialize it +with `git submodule update --init --recursive`. + +`--out` controls where generated support modules are written. It defaults to +`tangle_cli/generated`, which is the package support module used by the public +`tangle_cli/client.py` wrapper. Codegen writes exactly these support files: + +```text +/__init__.py +/models.py +/operations.py +``` + +The public client remains handwritten at `tangle_cli/client.py`; codegen does not +create a default generated public client wrapper. + +To regenerate from the already checked-in snapshot instead of the backend, pass +`--from-snapshot` explicitly: + +```bash +uv run python -m tangle_cli.openapi.codegen --from-snapshot +``` + +If you already have a remote OpenAPI JSON document, fetch that directly instead: + +```bash +uv run python -m tangle_cli.openapi.codegen --openapi-url https://example.com/openapi.json --out tangle_cli/generated +``` + +For example, the raw GitHub snapshot form is expressible as: + +```bash +uv run python -m tangle_cli.openapi.codegen --openapi-url https://raw.githubusercontent.com/TangleML/tangle/master/openapi.json +``` + +Downstream tools can point `--out` at their own generated support package, e.g.: + +```bash +uv run python -m tangle_cli.openapi.codegen --openapi-url https://oasis.shopify.io/openapi.json --out src/tangle_deploy/generated_api +``` + +At the time of writing the official repository does not commit that raw +`openapi.json`, so the submodule backend import flow above is the recommended +official regeneration path. diff --git a/pyproject.toml b/pyproject.toml index c6ce4d2..9add95b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,9 @@ dependencies = [ "cyclopts>=4.16.1", "httpx>=0.28.1", "platformdirs>=4.10.0", + "pydantic>=2.0", "pyyaml>=6.0", + "requests>=2.32.0", ] [project.urls] @@ -37,7 +39,25 @@ module-root = "" [tool.uv.sources] cloud-pipelines-backend = { git = "https://github.com/TangleML/tangle", rev = "stable_cli" } +[tool.pytest.ini_options] +testpaths = ["tests"] + [dependency-groups] dev = [ "pytest>=9.0.2", ] +codegen = [ + # Mirrors third_party/tangle backend import requirements used by + # `python -m tangle_cli.openapi.codegen --backend-path third_party/tangle`. + "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/__init__.py b/tangle_cli/__init__.py index 79e4d72..eeb01f1 100644 --- a/tangle_cli/__init__.py +++ b/tangle_cli/__init__.py @@ -1,5 +1,6 @@ """tangle-cli public API.""" from tangle_cli.api_client import TangleOpenApiClient +from tangle_cli.client import TangleApiClient -__all__ = ["TangleOpenApiClient"] +__all__ = ["TangleApiClient", "TangleOpenApiClient"] diff --git a/tangle_cli/api_cli.py b/tangle_cli/api_cli.py index 9015292..08d020f 100644 --- a/tangle_cli/api_cli.py +++ b/tangle_cli/api_cli.py @@ -2,9 +2,11 @@ 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. 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 +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. """ @@ -68,6 +70,7 @@ default_token, request_operation, ) +from .openapi.parser import load_openapi_schema as load_bundled_openapi_schema BaseUrlOption = Annotated[ str | None, @@ -106,21 +109,35 @@ 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, dynamic commands are generated from it. Otherwise - schema loading is driven by the current top-level CLI invocation: only actual - `tangle api ...` commands read the cache or fetch `/openapi.json`. + 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 cached OpenAPI schema.", + 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: @@ -141,6 +158,7 @@ def register_dynamic_commands(api_app: App, schema: dict[str, Any]) -> None: 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) @@ -148,6 +166,14 @@ def register_dynamic_commands(api_app: App, schema: dict[str, Any]) -> None: 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( @@ -177,6 +203,22 @@ def refresh( 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) -> None: + """Delete the cached live OpenAPI schema for a base URL.""" + + normalized_base_url = _normalize_base_url(base_url) if 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. @@ -258,6 +300,14 @@ def _operation_signature(operation: OperationCommand) -> inspect.Signature: ) ) + parameters.append( + inspect.Parameter( + "schema_source", + inspect.Parameter.KEYWORD_ONLY, + default="auto", + annotation=SchemaSourceOption, + ) + ) parameters.append( inspect.Parameter( "auth_header", @@ -314,6 +364,7 @@ def _operation_help(operation: OperationCommand) -> str: def _invoke_operation(operation: OperationCommand, values: dict[str, Any]) -> None: """Turn parsed CLI values into an HTTP request and print the response.""" + _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) or default_token() auth_header = values.pop("auth_header", None) @@ -354,37 +405,76 @@ def _invoke_operation(operation: OperationCommand, values: dict[str, Any]) -> No def _schema_for_current_invocation() -> dict[str, Any] | None: - """Return schema needed to build dynamic commands for this process. + """Return schema needed to build API commands for this process. - We prefer the cache and only fetch on API-focused invocations. If the user - is dispatching a dynamic command, fetch failures are made actionable instead - of letting Cyclopts report that only the static `refresh` command exists. + 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 + schema_source = _schema_source_from_argv(api_tail) + official = load_bundled_openapi_schema() + if schema_source == "official": + return official + base_url = _base_url_from_argv(api_tail) or default_base_url() cached = load_cached_schema(base_url) - if cached is not None: + if schema_source == "cache": + 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 use `--schema-source official` to use the official static schema." + ) return cached - if not _argv_requests_api_schema(sys.argv): - return None - try: - return load_or_fetch_schema( - base_url, - _token_from_argv(api_tail), - _headers_from_argv(api_tail), - _auth_header_from_argv(api_tail), - ) - except Exception as exc: - if _argv_dispatches_dynamic_command(sys.argv): - raise SystemExit(_schema_fetch_failure_message(base_url, exc)) from exc - # Keep `tangle api --help` usable if the backend is unavailable; the - # explicit `refresh` command or an attempted dynamic command reports the - # concrete fetch failure and next step. - return None + if cached is None: + return official + return _merge_official_with_cached_extensions(official, cached) + + +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: @@ -422,7 +512,15 @@ def _api_argv_tail(argv: list[str]) -> list[str] | 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"} + options_with_values = { + "--base-url", + "--api-url", + "--token", + "--auth-header", + "--header", + "-H", + "--schema-source", + } for arg in api_tail: if skip_next: skip_next = False @@ -438,6 +536,18 @@ def _api_first_command(api_tail: list[str]) -> str | None: return None +def _schema_source_from_argv(argv: list[str]) -> str: + value = _option_from_argv(argv, "--schema-source") or "auto" + return _validate_schema_source(value) + + +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}" diff --git a/tangle_cli/client.py b/tangle_cli/client.py new file mode 100644 index 0000000..3389914 --- /dev/null +++ b/tangle_cli/client.py @@ -0,0 +1,572 @@ +"""Static public Tangle API client. + +``TangleApiClient`` is the stable wrapper class consumed by downstream tools. +Endpoint methods are generated offline into :mod:`tangle_cli.generated.operations` +from the checked-in OpenAPI snapshot; handwritten methods in this file keep the +higher-level compatibility helpers that downstream callers use. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import asdict, is_dataclass +from typing import Any +from urllib.parse import quote, urljoin + +import requests + +from .api_transport import ( + DEFAULT_TIMEOUT_SECONDS, + _normalize_base_url, + _request_headers, + default_base_url, + default_token, +) +from .generated.operations import GeneratedOperationsMixin +from .logger import Logger, _null_logger +from .models import ( + ArtifactInfo, + ComponentInfo, + ComponentSpec, + ContainerState, + ExecutionDetails, + GraphExecutionState, + PipelineRun, + RunDetails, + SecretInfo, + TaskSpec, + UserInfo, +) + + +class TangleApiClient(GeneratedOperationsMixin): + """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 OpenAPI client. No + OpenAPI schema is loaded at runtime; all endpoint wrappers are checked in. + """ + + 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, + ) -> None: + self.base_url = _normalize_base_url(base_url or default_base_url()) + self.logger = logger or _null_logger + self.verbose = verbose + 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() + + 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) + request_headers = self._headers(extra_headers) + url = self._url(path) + clean_params = self._clean_mapping(params) + + self._refresh_auth() + # Refresh may mutate headers/session state, so build headers again. + request_headers = self._headers(extra_headers) + if self.verbose: + self.logger.info(f"{method.upper()} {url}") + response = self.session.request( + method.upper(), + url, + params=clean_params, + json=json_data, + headers=request_headers, + timeout=timeout, + **kwargs, + ) + if response.status_code == 401: + self._refresh_auth() + request_headers = self._headers(extra_headers) + response = self.session.request( + method.upper(), + url, + params=clean_params, + json=json_data, + headers=request_headers, + timeout=timeout, + **kwargs, + ) + return response + + 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) + 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 or default_token(), + self.header, + self.auth_header, + headers, + ) + + def _url(self, path: str) -> str: + if path.startswith("http://") or path.startswith("https://"): + return path + return urljoin(self.base_url.rstrip("/") + "/", path.lstrip("/")) + + @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 + + # ---- Compatibility helpers consumed by tangle-deploy ----------------- + + def get_artifact(self, artifact_id: str) -> ArtifactInfo: + return ArtifactInfo.from_dict(_to_plain(self.artifacts_get(artifact_id))) + + def get_artifact_signed_url(self, artifact_id: str) -> str | dict[str, Any] | None: + data = _to_plain(self.artifacts_signed_artifact_url(artifact_id)) + return data.get("signed_url") if isinstance(data, dict) else data + + def get_execution_details(self, execution_id: str) -> ExecutionDetails: + details = ExecutionDetails.from_dict(_to_plain(self.executions_details(execution_id))) + self._enrich_execution_tree(details) + return details + + def get_execution_graph_state(self, execution_id: str) -> GraphExecutionState: + return GraphExecutionState.from_dict(_to_plain(self.executions_graph_execution_state(execution_id))) + + def get_execution_graph_state_alt(self, execution_id: str) -> GraphExecutionState: + return GraphExecutionState.from_dict(_to_plain(self.executions_state(execution_id))) + + def get_execution_container_state(self, execution_id: str) -> ContainerState: + return ContainerState.from_dict(_to_plain(self.executions_container_state(execution_id))) + + def get_execution_artifacts(self, execution_id: str) -> dict[str, Any]: + return _to_plain(self.executions_artifacts(execution_id)) + + def get_execution_container_log(self, execution_id: str) -> str | dict[str, Any] | None: + data = _to_plain(self.executions_container_log(execution_id)) + if isinstance(data, dict) and "log_text" in data: + return data.get("log_text") + return data + + 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 list_pipeline_runs( + self, + page_token: str | None = None, + filter: str | None = None, + filter_query: str | None = None, + include_pipeline_names: bool = False, + include_execution_stats: bool = False, + ) -> dict[str, Any]: + return _to_plain( + self.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 create_pipeline_run( + self, + root_task: Any, + components: list[Any] | None = None, + annotations: dict[str, Any] | None = None, + ) -> PipelineRun: + body = { + "root_task": _to_plain(root_task), + "components": _to_plain(components), + "annotations": annotations, + } + return PipelineRun.from_dict( + self._request_json("POST", "/api/pipeline_runs/", json_data=self._clean_mapping(body)) + ) + + def get_pipeline_run(self, run_id: str) -> PipelineRun: + return PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) + + def cancel_pipeline_run(self, run_id: str) -> None: + self.pipeline_runs_cancel(run_id) + return None + + def list_pipeline_run_annotations(self, run_id: str) -> dict[str, str | None]: + data = self.pipeline_runs_annotations(run_id) + return data if isinstance(data, dict) else {} + + def set_pipeline_run_annotation( + self, + run_id: str, + key: str, + value: str | None = None, + ) -> None: + self.pipeline_runs_put_annotations(run_id, key, value=value) + return None + + def delete_pipeline_run_annotation(self, run_id: str, key: str) -> None: + self.pipeline_runs_delete_annotations(run_id, key) + return None + + def get_current_user(self) -> UserInfo | None: + data = _to_plain(self.users_me()) + if data is None: + return None + return UserInfo(id=data.get("id"), permissions=data.get("permissions", [])) + + def get_component(self, digest: str) -> ComponentSpec: + return ComponentSpec.from_dict(_to_plain(self.components_get(digest))) + + def get_component_spec(self, digest: str) -> ComponentSpec: + return self.get_component(digest) + + def resolve_digest(self, digest: str) -> str: + """Resolve a component identifier to a digest when possible.""" + + if digest.startswith("sha256:"): + return digest + matches = self.list_published_components(digest=digest) + if len(matches) == 1 and matches[0].get("digest"): + return str(matches[0]["digest"]) + matches = self.list_published_components(name_substring=digest) + if len(matches) == 1 and matches[0].get("digest"): + return str(matches[0]["digest"]) + return digest + + def list_published_components( + 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.list_published_components( + 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]] | None = None, + *, + names: Iterable[str] | None = None, + digests: Iterable[str] | None = None, + include_deprecated: bool = False, + ) -> dict[str, ComponentInfo]: + """Find published components matching provided component specs/names/digests. + + The result is keyed by every useful identifier we can infer (digest and + name), which keeps the helper compatible with callers that check either. + """ + + search_names = set(names or []) + search_digests = set(digests or []) + for component in components or []: + data = _to_plain(component) + if 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, dict): + if data.get("name"): + search_names.add(str(data["name"])) + if data.get("digest"): + search_digests.add(str(data["digest"])) + + found: dict[str, ComponentInfo] = {} + for digest in search_digests: + for info in self.list_published_component_infos( + include_deprecated=include_deprecated, + digest=digest, + ): + _index_component_info(found, info) + for name in search_names: + for info in self.list_published_component_infos( + include_deprecated=include_deprecated, + name_substring=name, + ): + if info.name == name: + _index_component_info(found, info) + return found + + def publish_component(self, component_reference: dict[str, Any]) -> dict[str, Any]: + return _to_plain( + self._request_json( + "POST", + "/api/published_components/", + json_data=_to_plain(component_reference), + ) + ) + + def update_published_component( + self, + digest: str, + deprecated: bool | None = None, + superseded_by: str | None = None, + ) -> dict[str, Any]: + return _to_plain( + self.published_components_update( + digest, + deprecated=deprecated, + superseded_by=superseded_by, + ) + ) + + def get_component_search_schema(self) -> dict[str, Any]: + return _to_plain( + self._request_json( + "GET", + "/api/published_components/experimental/search/schema", + ) + ) + + def search_components_v2(self, *, body: dict[str, Any]) -> dict[str, Any]: + return _to_plain( + self._request_json( + "POST", + "/api/published_components/experimental/search", + json_data=body, + ) + ) + + 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: + run = self.get_pipeline_run(run_id) + root_execution_id = execution_id or run.root_execution_id + execution = self.get_execution_details(root_execution_id) if root_execution_id else None + if execution and not include_implementations: + execution.strip_implementations() + annotations = self.list_pipeline_run_annotations(run_id) if include_annotations else None + execution_state = ( + self.get_execution_graph_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: + details = self.get_run_details(run_id, include_implementations=True) + return details.execution.task_spec if details.execution else None + + def list_secrets(self) -> list[SecretInfo]: + data = _to_plain(self.secrets_list()) + rows = data.get("secrets", []) if isinstance(data, dict) else data or [] + return [SecretInfo.from_dict(row) for row in rows] + + def create_secret( + self, + secret_name: str, + secret_value: str, + description: str | None = None, + expires_at: str | None = None, + ) -> SecretInfo: + return SecretInfo.from_dict( + _to_plain( + self.secrets_create( + secret_name=secret_name, + secret_value=secret_value, + description=description, + expires_at=expires_at, + ) + ) + ) + + def update_secret( + self, + secret_name: str, + secret_value: str, + description: str | None = None, + expires_at: str | None = None, + ) -> SecretInfo: + return SecretInfo.from_dict( + _to_plain( + self.secrets_update( + secret_name=secret_name, + secret_value=secret_value, + description=description, + expires_at=expires_at, + ) + ) + ) + + def delete_secret(self, secret_name: str) -> None: + self.secrets_delete(secret_name) + return None + + def _enrich_execution_tree(self, execution: ExecutionDetails) -> None: + child_ids = execution.raw.get("child_task_execution_ids") or {} + if not isinstance(child_ids, dict): + return + for task_name, child_execution_id in child_ids.items(): + if not child_execution_id: + continue + child = ExecutionDetails.from_dict(_to_plain(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) + if task is not None: + task.raw["execution_id"] = child.id + task.raw["input_artifacts"] = child.input_artifacts + task.raw["output_artifacts"] = child.output_artifacts + + +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 + + +def _index_component_info(index: dict[str, ComponentInfo], info: ComponentInfo) -> None: + if info.digest: + index[info.digest] = info + if info.name: + index[info.name] = info + + +__all__ = ["TangleApiClient"] diff --git a/tangle_cli/generated/__init__.py b/tangle_cli/generated/__init__.py new file mode 100644 index 0000000..2570bb6 --- /dev/null +++ b/tangle_cli/generated/__init__.py @@ -0,0 +1 @@ +"""Generated OpenAPI support modules.""" diff --git a/tangle_cli/generated/models.py b/tangle_cli/generated/models.py new file mode 100644 index 0000000..98bc2ba --- /dev/null +++ b/tangle_cli/generated/models.py @@ -0,0 +1,325 @@ +"""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 BaseModel, Field + +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) + +class ArtifactData(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 ArtifactDataResponse(TangleGeneratedModel): + is_dir: Any = None + total_size: Any = None + uri: Any = None + value: Any = None + +class ArtifactNodeIdResponse(TangleGeneratedModel): + id: Any = None + +class ArtifactNodeResponse(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 BodyCreateApiPipelineRunsPost(TangleGeneratedModel): + annotations: Any = None + components: Any = None + root_task: Any = None + +class BodyCreateSecretApiSecretsPost(TangleGeneratedModel): + secret_value: Any = None + +class BodySetSettingsApiUsersMeSettingsPatch(TangleGeneratedModel): + settings: Any = None + +class BodyUpdateSecretApiSecretsSecretNamePut(TangleGeneratedModel): + secret_value: Any = None + +class CachingStrategySpec(TangleGeneratedModel): + maxcachestaleness: Any = Field(default=None, alias='maxCacheStaleness') + +class ComponentLibrary(TangleGeneratedModel): + annotations: Any = None + name: Any = None + root_folder: Any = None + +class ComponentLibraryFolder(TangleGeneratedModel): + annotations: Any = None + components: Any = None + folders: Any = None + name: Any = None + +class ComponentLibraryResponse(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 ComponentReference(TangleGeneratedModel): + digest: Any = None + name: Any = None + spec: Any = None + tag: Any = None + text: Any = None + url: Any = None + +class ComponentResponse(TangleGeneratedModel): + digest: Any = None + text: Any = None + +class ComponentSpec(TangleGeneratedModel): + description: Any = None + implementation: Any = None + inputs: Any = None + metadata: Any = None + name: Any = None + outputs: Any = None + +class ConcatPlaceholder(TangleGeneratedModel): + concat: Any = None + +ContainerExecutionStatus = Any + +class ContainerImplementation(TangleGeneratedModel): + container: Any = None + +class ContainerSpec(TangleGeneratedModel): + args: Any = None + command: Any = None + env: Any = None + image: Any = None + +class DynamicDataArgument(TangleGeneratedModel): + dynamicdata: Any = Field(default=None, alias='dynamicData') + +class ExecutionNodeReference(TangleGeneratedModel): + execution_node_id: Any = None + pipeline_run_id: Any = None + +class ExecutionOptionsSpec(TangleGeneratedModel): + cachingstrategy: Any = Field(default=None, alias='cachingStrategy') + retrystrategy: Any = Field(default=None, alias='retryStrategy') + +class ExecutionStatusSummary(TangleGeneratedModel): + ended_executions: Any = None + has_ended: Any = None + total_executions: Any = None + +class GetArtifactInfoResponse(TangleGeneratedModel): + artifact_data: Any = None + id: Any = None + +class GetArtifactSignedUrlResponse(TangleGeneratedModel): + signed_url: Any = None + +class GetContainerExecutionLogResponse(TangleGeneratedModel): + log_text: Any = None + orchestration_error_message: Any = None + system_error_exception_full: Any = None + +class GetContainerExecutionStateResponse(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 GetExecutionArtifactsResponse(TangleGeneratedModel): + input_artifacts: Any = None + output_artifacts: Any = None + +class GetExecutionInfoResponse(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 GetGraphExecutionStateResponse(TangleGeneratedModel): + child_execution_status_stats: Any = None + child_execution_status_summary: Any = None + +class GetUserResponse(TangleGeneratedModel): + id: Any = None + permissions: Any = None + +class GraphImplementation(TangleGeneratedModel): + graph: Any = None + +class GraphInputArgument(TangleGeneratedModel): + graphinput: Any = Field(default=None, alias='graphInput') + +class GraphInputReference(TangleGeneratedModel): + inputname: Any = Field(default=None, alias='inputName') + type: Any = None + +class GraphSpec(TangleGeneratedModel): + outputvalues: Any = Field(default=None, alias='outputValues') + tasks: Any = None + +class HTTPValidationError(TangleGeneratedModel): + detail: Any = None + +class IfPlaceholder(TangleGeneratedModel): + if_: Any = Field(default=None, alias='if') + +class IfPlaceholderStructure(TangleGeneratedModel): + cond: Any = None + else_: Any = Field(default=None, alias='else') + then: Any = None + +class InputPathPlaceholder(TangleGeneratedModel): + inputpath: Any = Field(default=None, alias='inputPath') + +class InputSpec(TangleGeneratedModel): + annotations: Any = None + default: Any = None + description: Any = None + name: Any = None + optional: Any = None + type: Any = None + +class InputValuePlaceholder(TangleGeneratedModel): + inputvalue: Any = Field(default=None, alias='inputValue') + +class IsPresentPlaceholder(TangleGeneratedModel): + ispresent: Any = Field(default=None, alias='isPresent') + +class ListComponentLibrariesResponse(TangleGeneratedModel): + component_libraries: Any = None + +class ListPipelineJobsResponse(TangleGeneratedModel): + next_page_token: Any = None + pipeline_runs: Any = None + +class ListPublishedComponentsResponse(TangleGeneratedModel): + published_components: Any = None + +class ListSecretsResponse(TangleGeneratedModel): + secrets: Any = None + +class MetadataSpec(TangleGeneratedModel): + annotations: Any = None + labels: Any = None + +class OutputPathPlaceholder(TangleGeneratedModel): + outputpath: Any = Field(default=None, alias='outputPath') + +class OutputSpec(TangleGeneratedModel): + annotations: Any = None + description: Any = None + name: Any = None + type: Any = None + +class PipelineRunResponse(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 PublishedComponentResponse(TangleGeneratedModel): + deprecated: Any = None + digest: Any = None + name: Any = None + published_by: Any = None + superseded_by: Any = None + url: Any = None + +class RetryStrategySpec(TangleGeneratedModel): + maxretries: Any = Field(default=None, alias='maxRetries') + +class SecretInfoResponse(TangleGeneratedModel): + created_at: Any = None + description: Any = None + expires_at: Any = None + secret_name: Any = None + updated_at: Any = None + +class TaskOutputArgument(TangleGeneratedModel): + taskoutput: Any = Field(default=None, alias='taskOutput') + +class TaskOutputReference(TangleGeneratedModel): + outputname: Any = Field(default=None, alias='outputName') + taskid: Any = Field(default=None, alias='taskId') + +class TaskSpec(TangleGeneratedModel): + annotations: Any = None + arguments: Any = None + componentref: Any = Field(default=None, alias='componentRef') + executionoptions: Any = Field(default=None, alias='executionOptions') + isenabled: Any = Field(default=None, alias='isEnabled') + +class UserComponentLibraryPinsResponse(TangleGeneratedModel): + component_library_ids: Any = None + +class UserSettingsResponse(TangleGeneratedModel): + settings: Any = None + +class ValidationError(TangleGeneratedModel): + ctx: Any = None + input: Any = None + loc: Any = None + msg: Any = None + type: Any = None + +__all__ = ['TangleGeneratedModel', '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/tangle_cli/generated/operations.py b/tangle_cli/generated/operations.py new file mode 100644 index 0000000..5fccd82 --- /dev/null +++ b/tangle_cli/generated/operations.py @@ -0,0 +1,376 @@ +"""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 typing import Any + +from .models import ComponentLibraryResponse, ComponentResponse, GetArtifactInfoResponse, GetArtifactSignedUrlResponse, GetContainerExecutionLogResponse, GetContainerExecutionStateResponse, GetExecutionArtifactsResponse, GetExecutionInfoResponse, GetGraphExecutionStateResponse, GetUserResponse, ListComponentLibrariesResponse, ListPipelineJobsResponse, ListPublishedComponentsResponse, ListSecretsResponse, PipelineRunResponse, PublishedComponentResponse, SecretInfoResponse, UserComponentLibraryPinsResponse, UserSettingsResponse + + +class GeneratedOperationsMixin: + """Mixin containing one checked-in method per OpenAPI operation.""" + + def admin_execution_node_status(self, id: Any, status: Any) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + return self._request_json( + 'POST', + '/api/published_components/', + path_params=None, + params=None, + json_data={'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}, + response_model=PublishedComponentResponse, + ) + + def published_components_update(self, digest: Any, deprecated: Any = None, superseded_by: Any = None) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + 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) -> Any: + return self._request_json( + 'DELETE', + '/api/users/me/settings', + path_params=None, + params={'setting_names': setting_names}, + json_data=None, + response_model=None, + ) + +__all__ = ['GeneratedOperationsMixin'] diff --git a/tangle_cli/models.py b/tangle_cli/models.py index f61851a..b7f8633 100644 --- a/tangle_cli/models.py +++ b/tangle_cli/models.py @@ -8,7 +8,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from typing import Any import yaml @@ -718,3 +718,45 @@ class PageChunk: 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, + ExecutionDetails, + KubernetesDebugInfo, + KubernetesJobInfo, + DebugInfo, + ContainerState, + RunDetails, + ArtifactComponentQuery, + ArtifactInfo, + UserInfo, + SecretInfo, + ComponentSpec, + 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/tangle_cli/openapi/__init__.py b/tangle_cli/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py new file mode 100644 index 0000000..e18b809 --- /dev/null +++ b/tangle_cli/openapi/codegen.py @@ -0,0 +1,471 @@ +"""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 importlib +import json +import keyword +import os +import re +import sys +import tempfile +import urllib.request +from pathlib import Path +from typing import Any + +from .parser import DEFAULT_OPENAPI_PATH, load_openapi_schema, parsed_operations + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_GENERATED_DIR = _REPO_ROOT / "tangle_cli" / "generated" +DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle" + + +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) -> str | None: + if not schema: + return None + ref = schema.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + return _class_name(ref.rsplit("/", 1)[1]) + for key in ("anyOf", "oneOf", "allOf"): + for child in schema.get(key, []) or []: + name = _schema_ref_name(child) + if name: + return name + return None + + +def _success_schema(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) + if not isinstance(response, dict): + 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 _response_model_name(operation: dict[str, Any]) -> str | None: + return _schema_ref_name(_success_schema(operation)) + + +def generate_models(schema: dict[str, Any]) -> str: + schemas = schema.get("components", {}).get("schemas", {}) or {} + 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 BaseModel, Field", + "", + "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)", + "", + ] + + exports = ["TangleGeneratedModel"] + 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 {} + lines.extend([f"class {class_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("") + + 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 _param_signature(parameters: list[Any], has_request_body: bool) -> tuple[str, list[str], list[str], list[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] = [] + 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) + include_body = has_request_body and not body_names + if include_body: + signature_parts.append("body: Any = None") + return ", ".join(signature_parts), path_names, query_names, 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 generate_operations(schema: dict[str, Any]) -> str: + operations = parsed_operations(schema) + response_models = sorted({name for op in operations if (name := _response_model_name(op.operation))}) + 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 typing import Any", + "", + ] + if imports: + lines.extend([f"from .models import {imports}", ""]) + + lines.extend([ + "", + "class GeneratedOperationsMixin:", + " \"\"\"Mixin containing one checked-in method per OpenAPI operation.\"\"\"", + "", + ]) + + used_methods: set[str] = set() + for operation in operations: + 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, include_body = _param_signature( + list(operation.parameters), operation.has_request_body + ) + response_model = _response_model_name(operation.operation) + response_arg = response_model if response_model else "None" + if signature: + def_line = f" def {method_name}(self, {signature}) -> Any:" + else: + def_line = f" def {method_name}(self) -> Any:" + 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={_dict_literal(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__ = {[ 'GeneratedOperationsMixin' ]!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 = DEFAULT_OPENAPI_PATH, + generated_dir: str | Path = _GENERATED_DIR, +) -> 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), encoding="utf-8") + generated_files[2].write_text(generate_operations(schema), encoding="utf-8") + return schema, generated_files + + +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=str(DEFAULT_OPENAPI_PATH), help="Path to openapi.json") + parser.add_argument( + "--out", + default=str(_GENERATED_DIR), + help="Generated support module directory (default: tangle_cli/generated).", + ) + 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 existing local openapi.json snapshot.", + ) + args = parser.parse_args(argv) + 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") + + wrote_openapi = False + if args.openapi_url: + update_openapi_from_url(args.openapi_url, destination=args.openapi) + source = f"URL: {args.openapi_url}" + wrote_openapi = True + elif args.from_snapshot: + source = f"snapshot: {_display_path(args.openapi)}" + 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=args.openapi, + database_uri=args.backend_database_uri, + ) + source = f"backend: {_display_path(backend_path)}" + wrote_openapi = True + + schema, generated_files = generate(args.openapi, args.out) + _print_summary( + source=source, + openapi_path=args.openapi, + generated_files=generated_files, + schema=schema, + wrote_openapi=wrote_openapi, + ) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tangle_cli/openapi/openapi.json b/tangle_cli/openapi/openapi.json new file mode 100644 index 0000000..adf8e72 --- /dev/null +++ b/tangle_cli/openapi/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/tangle_cli/openapi/parser.py b/tangle_cli/openapi/parser.py new file mode 100644 index 0000000..3633dff --- /dev/null +++ b/tangle_cli/openapi/parser.py @@ -0,0 +1,34 @@ +"""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 ``openapi.json`` snapshot. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from tangle_cli.api_schema import OperationCommand, operation_commands + +PACKAGE_DIR = Path(__file__).resolve().parent +DEFAULT_OPENAPI_PATH = PACKAGE_DIR / "openapi.json" + + +def load_openapi_schema(path: str | Path | None = None) -> dict[str, Any]: + """Load a Tangle OpenAPI schema from disk.""" + + schema_path = Path(path) if path is not None else DEFAULT_OPENAPI_PATH + 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 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/tests/test_api_cli.py b/tests/test_api_cli.py index 07094d2..b634729 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -458,17 +458,18 @@ def fake_get(url, **kwargs): assert not api_cli._argv_dispatches_dynamic_command(api_cli.sys.argv) -def test_cache_miss_fetches_schema_before_dynamic_dispatch(monkeypatch, tmp_path): +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}) - return json_response("GET", url, SCHEMA) + 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, {"digest": "sha256:abc"}) + return json_response(method, url, {"published_components": []}) monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(api_cli.httpx, "get", fake_get) @@ -479,86 +480,283 @@ def fake_request(method, url, **kwargs): [ "tangle", "api", - "components", - "get", - "sha256:abc", + "published-components", + "list", + "--name-substring", + "scrape v2", "--base-url", "http://api.test", - "--auth-header", - "Basic cli-auth", - "-H", - "Cloud-Auth: cli-value", ], ) app = api_cli.build_app() - schema_headers = lower_headers(gets[0]["headers"]) - assert gets[0]["url"] == "http://api.test/openapi.json" - assert schema_headers["authorization"] == "Basic cli-auth" - assert schema_headers["cloud-auth"] == "cli-value" - assert gets[0]["timeout"] == api_cli.DEFAULT_TIMEOUT_SECONDS + assert gets == [] run_app( app, [ - "components", - "get", - "sha256:abc", + "published-components", + "list", + "--name-substring", + "scrape v2", "--base-url", "http://api.test", - "--auth-header", - "Basic cli-auth", ], ) - assert requests[-1]["url"] == "http://api.test/api/components/sha256%3Aabc" + assert requests[-1]["method"] == "GET" + assert requests[-1]["url"] == "http://api.test/api/published_components/?name_substring=scrape+v2" -def test_cold_cache_api_short_help_does_not_treat_help_as_dynamic_command( +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", "-h"]) + monkeypatch.setattr(api_cli.sys, "argv", ["tangle", "api", "--help"]) app = api_cli.build_app() - run_app(app, ["-h"]) + 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 test_cache_miss_dynamic_fetch_failure_is_actionable(monkeypatch, tmp_path): - def fake_get(url, **kwargs): - request = httpx.Request("GET", url) - raise httpx.ConnectError("backend unavailable", request=request) +def _oasis_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.httpx, "get", fake_get) monkeypatch.setattr( api_cli.sys, "argv", - [ - "tangle", - "api", - "components", - "get", - "sha256:abc", - "--auth-header", - "Basic secret-value", - ], + ["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( + _oasis_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( + _oasis_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( + _oasis_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( + _oasis_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() - message = str(exc_info.value) - assert "tangle api refresh" in message - assert "secret-value" not in message + 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_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( + _oasis_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): diff --git a/tests/test_codegen.py b/tests/test_codegen.py new file mode 100644 index 0000000..b6cd959 --- /dev/null +++ b/tests/test_codegen.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tangle_cli.openapi import codegen + + +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_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" + backend.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): + calls.append(("generate", {"openapi_path": openapi_path, "generated_dir": generated_dir})) + return _schema(), _generated_files(tmp_path) + + monkeypatch.setattr(codegen, "DEFAULT_BACKEND_PATH", backend) + monkeypatch.setattr(codegen, "update_openapi_from_backend", fake_update_openapi_from_backend) + monkeypatch.setattr(codegen, "generate", fake_generate) + + codegen.main([ + "--openapi", + str(tmp_path / "openapi.json"), + "--out", + str(tmp_path / "generated"), + ]) + + assert calls[0][0] == "update" + assert calls[0][1]["backend_path"] == backend + assert calls[1][0] == "generate" + output = capsys.readouterr().out + assert f"Loaded OpenAPI from backend: {backend}" in output + assert f"Wrote {tmp_path / 'openapi.json'}" 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): + calls.append(("generate", {"openapi_path": openapi_path, "generated_dir": generated_dir})) + 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" + 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_fetches_from_openapi_url_before_generating( + monkeypatch, tmp_path, capsys +) -> None: + calls: list[tuple[str, object]] = [] + + def fake_update_openapi_from_url(openapi_url, **kwargs): + calls.append(("update-url", {"openapi_url": openapi_url, **kwargs})) + openapi_path = tmp_path / "openapi.json" + openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") + return openapi_path + + def fake_generate(openapi_path, generated_dir): + calls.append(("generate", {"openapi_path": openapi_path, "generated_dir": generated_dir})) + 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(tmp_path / "openapi.json"), + "--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(tmp_path / "openapi.json"), + }, + ) + assert calls[1][0] == "generate" + output = capsys.readouterr().out + assert "Loaded OpenAPI from URL: https://example.com/openapi.json" in output + assert "Generated 1 operations from 1 paths" in output + + +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 GeneratedOperationsMixin" in operations + assert "def published_components_list" in operations + assert "name_substring" in operations diff --git a/tests/test_static_client.py b/tests/test_static_client.py new file mode 100644 index 0000000..de28dfa --- /dev/null +++ b/tests/test_static_client.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +from typing import Any + +import requests + +from tangle_cli import TangleApiClient +from tangle_cli.generated.models import PipelineRunResponse +from tangle_cli.models import PipelineRun, SecretInfo + + +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_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_compat_get_pipeline_run_returns_dataclass_with_dict_helpers() -> None: + session = FakeSession([ + response({"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}) + ]) + client = TangleApiClient("https://api.test", session=session) + + run = client.get_pipeline_run("run-1") + + assert isinstance(run, PipelineRun) + assert run.id == "run-1" + assert run.get("created_by") == "alice" + assert run["root_execution_id"] == "exec-1" + + +def test_secret_helpers_use_static_generated_endpoint_shapes() -> 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.create_secret("demo", "value", description="d") + + assert isinstance(secret, SecretInfo) + 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"} + + +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_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index d492940..d8e2765 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -5,10 +5,11 @@ import importlib.util -def test_tangle_deploy_required_import_surface_uses_openapi_client_only() -> None: +def test_tangle_deploy_required_import_surface_includes_static_client() -> None: import tangle_cli - from tangle_cli import TangleOpenApiClient, utils as utils_module + from tangle_cli import TangleApiClient, TangleOpenApiClient, utils as utils_module from tangle_cli.api_client import TangleOpenApiClient as OpenApiClient + from tangle_cli.client import TangleApiClient as StaticClient from tangle_cli.component_inspector import ( get_standard_library, inspect_by_digest, @@ -87,10 +88,10 @@ def test_tangle_deploy_required_import_surface_uses_openapi_client_only() -> Non ) assert TangleOpenApiClient.__name__ == OpenApiClient.__name__ == "TangleOpenApiClient" - old_client_name = "Tangle" + "ApiClient" - old_client_module = "tangle_cli." + "client" - assert not hasattr(tangle_cli, old_client_name) - assert importlib.util.find_spec(old_client_module) is None + assert TangleApiClient.__name__ == StaticClient.__name__ == "TangleApiClient" + assert hasattr(tangle_cli, "TangleApiClient") + assert importlib.util.find_spec("tangle_cli.client") is not None + assert callable(TangleApiClient("https://api.test").set_verbose) assert ComponentSpec.__name__ == "ComponentSpec" assert PipelineRun.__name__ == "PipelineRun" assert callable(get_standard_library) 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 813fcf7..e60beff 100644 --- a/uv.lock +++ b/uv.lock @@ -7,169 +7,29 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[[package]] -name = "aiohappyeyeballs" -version = "2.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, -] +[options] +exclude-newer = "2026-06-04T01:22:41.228517Z" +exclude-newer-span = "P7D" [[package]] -name = "aiohttp" -version = "3.14.1" -source = { registry = "https://pypi.org/simple" } +name = "alembic" +version = "1.18.4" +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, - { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, - { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, - { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, - { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, - { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, - { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, - { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { 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" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, @@ -178,7 +38,7 @@ wheels = [ [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, @@ -187,7 +47,7 @@ wheels = [ [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, @@ -201,7 +61,7 @@ wheels = [ [[package]] name = "asgiref" version = "3.11.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -210,28 +70,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "26.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } 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://pkgs.shopify.io/basic/data/python/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.5.20" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } 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/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, @@ -240,7 +103,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -345,7 +208,7 @@ wheels = [ [[package]] name = "click" version = "8.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -357,7 +220,7 @@ wheels = [ [[package]] name = "cloud-pipelines" version = "0.26.3.12" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "docker" }, { name = "docstring-parser" }, @@ -389,7 +252,7 @@ dependencies = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -398,7 +261,7 @@ wheels = [ [[package]] name = "cyclopts" version = "4.16.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "attrs" }, { name = "docstring-parser" }, @@ -415,7 +278,7 @@ wheels = [ [[package]] name = "detect-installer" version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -424,7 +287,7 @@ wheels = [ [[package]] name = "dnspython" version = "2.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, @@ -433,7 +296,7 @@ wheels = [ [[package]] name = "docker" version = "7.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, @@ -447,7 +310,7 @@ wheels = [ [[package]] name = "docstring-parser" version = "0.18.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } 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/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" }, @@ -456,7 +319,7 @@ wheels = [ [[package]] name = "durationpy" version = "0.10" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, @@ -465,7 +328,7 @@ wheels = [ [[package]] name = "email-validator" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "dnspython" }, { name = "idna" }, @@ -478,7 +341,7 @@ wheels = [ [[package]] name = "exceptiongroup" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -490,7 +353,7 @@ wheels = [ [[package]] name = "fastapi" version = "0.136.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -519,7 +382,7 @@ standard = [ [[package]] name = "fastapi-cli" version = "0.0.24" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "rich-toolkit" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -540,7 +403,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" version = "0.19.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "detect-installer" }, { name = "fastar" }, @@ -560,7 +423,7 @@ wheels = [ [[package]] name = "fastar" version = "0.11.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -673,131 +536,10 @@ wheels = [ { 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 = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, - { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, - { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, - { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, - { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, - { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, - { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.75.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -809,7 +551,7 @@ wheels = [ [[package]] name = "greenlet" version = "3.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -879,7 +621,7 @@ wheels = [ [[package]] name = "grpcio" version = "1.81.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -940,7 +682,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -949,7 +691,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -962,7 +704,7 @@ wheels = [ [[package]] name = "httptools" version = "0.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -1012,7 +754,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -1027,7 +769,7 @@ wheels = [ [[package]] name = "idna" version = "3.18" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } 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/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, @@ -1036,7 +778,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -1045,7 +787,7 @@ wheels = [ [[package]] name = "isodate" version = "0.6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "six" }, ] @@ -1057,7 +799,7 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -1068,10 +810,9 @@ wheels = [ [[package]] name = "kubernetes" -version = "36.0.2" -source = { registry = "https://pypi.org/simple" } +version = "35.0.0" +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ - { name = "aiohttp" }, { name = "certifi" }, { name = "durationpy" }, { name = "python-dateutil" }, @@ -1082,15 +823,36 @@ dependencies = [ { name = "urllib3" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/57/8b538af5076bc3372949d76f70ba3449bdfe52f9e6488170fa5d4f7cbe70/kubernetes-36.0.2.tar.gz", hash = "sha256:03551fcb49cae1f708f63624041e37403545b7aaed10cbf54e2b01a37a5438e3", size = 2336738, upload-time = "2026-06-01T18:20:30.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" } +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://pkgs.shopify.io/basic/data/python/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://pkgs.shopify.io/basic/data/python/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/46/2c/5c160dbdef7123f8cc97fd8ece7e0198627a426a2a49614845e9086feb8d/kubernetes-36.0.2-py2.py3-none-any.whl", hash = "sha256:faf9b5241b58de0c4a5069f2a0ffc8ac06fece7215156cd3d3ba081a78a858b6", size = 4617568, upload-time = "2026-06-01T18:20:28.737Z" }, + { 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.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "mdurl" }, ] @@ -1102,7 +864,7 @@ wheels = [ [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, @@ -1187,154 +949,16 @@ wheels = [ [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, - { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, - { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, - { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - [[package]] name = "oauthlib" version = "3.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, @@ -1343,7 +967,7 @@ wheels = [ [[package]] name = "opentelemetry-api" version = "1.42.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -1355,7 +979,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.42.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "opentelemetry-proto" }, ] @@ -1367,7 +991,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.42.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, @@ -1385,7 +1009,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.42.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "googleapis-common-protos" }, { name = "opentelemetry-api" }, @@ -1403,7 +1027,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation" version = "0.63b1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1418,7 +1042,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation-asgi" version = "0.63b1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "asgiref" }, { name = "opentelemetry-api" }, @@ -1434,7 +1058,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation-fastapi" version = "0.63b1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, @@ -1450,7 +1074,7 @@ wheels = [ [[package]] name = "opentelemetry-proto" version = "1.42.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -1462,7 +1086,7 @@ wheels = [ [[package]] name = "opentelemetry-sdk" version = "1.42.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1476,7 +1100,7 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions" version = "0.63b1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, @@ -1489,7 +1113,7 @@ wheels = [ [[package]] name = "opentelemetry-util-http" version = "0.63b1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } 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/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" }, @@ -1498,7 +1122,7 @@ wheels = [ [[package]] name = "packaging" version = "26.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } 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/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, @@ -1507,7 +1131,7 @@ wheels = [ [[package]] name = "platformdirs" version = "4.10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -1516,144 +1140,16 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, - { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, - { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, - { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, - { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, - { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, - { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, - { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, - { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, - { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, - { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, - { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, - { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, - { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, - { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, - { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, - { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, - { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, - { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, - { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, - { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, - { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, - { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, - { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, - { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, - { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, - { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, - { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, - { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, - { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, - { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, - { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, -] - [[package]] name = "protobuf" version = "6.33.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, @@ -1668,7 +1164,7 @@ wheels = [ [[package]] name = "pydantic" version = "2.13.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, @@ -1688,7 +1184,7 @@ email = [ [[package]] name = "pydantic-core" version = "2.46.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -1804,7 +1300,7 @@ wheels = [ [[package]] name = "pydantic-extra-types" version = "2.11.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, @@ -1817,7 +1313,7 @@ wheels = [ [[package]] name = "pydantic-settings" version = "2.14.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -1831,7 +1327,7 @@ wheels = [ [[package]] name = "pygments" version = "2.20.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, @@ -1840,7 +1336,7 @@ wheels = [ [[package]] name = "pytest" version = "9.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -1858,7 +1354,7 @@ wheels = [ [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "six" }, ] @@ -1870,7 +1366,7 @@ wheels = [ [[package]] name = "python-dotenv" version = "1.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, @@ -1878,42 +1374,39 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.32" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +version = "0.0.30" +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, + { 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]] name = "pywin32" -version = "312" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, - { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, - { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, - { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, - { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, - { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, - { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +version = "311" +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, @@ -1977,7 +1470,7 @@ wheels = [ [[package]] name = "requests" version = "2.34.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -1992,7 +1485,7 @@ wheels = [ [[package]] name = "requests-oauthlib" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "oauthlib" }, { name = "requests" }, @@ -2005,7 +1498,7 @@ wheels = [ [[package]] name = "rich" version = "15.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, @@ -2018,7 +1511,7 @@ wheels = [ [[package]] name = "rich-rst" version = "2.0.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "pygments" }, { name = "rich" }, @@ -2030,22 +1523,22 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.20.1" -source = { registry = "https://pypi.org/simple" } +version = "0.20.0" +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/63/3e427c62f1992945c997d4ec31e2fcb37d26aadbe5aa44ae5b29f7f64d26/rich_toolkit-0.20.1.tar.gz", hash = "sha256:c7336ae281f435c785acecaedc4b71d4b663dc73d9c8079fea96372527e822a4", size = 203473, upload-time = "2026-06-05T08:56:57.679Z" } +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/00/88/309f07d08155da2ba1d5ceb42d270fb42fbe34a807684543e3ffc10fe713/rich_toolkit-0.20.1-py3-none-any.whl", hash = "sha256:2a6d5f8e15759b9eba5a9ee63da10b275359ead20e5a0fc92bd5b4dbae8ce4bf", size = 35525, upload-time = "2026-06-05T08:56:58.586Z" }, + { 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]] name = "rignore" version = "0.7.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" }, @@ -2165,21 +1658,21 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.62.0" -source = { registry = "https://pypi.org/simple" } +version = "2.61.1" +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/5d/a343201726150e05f2036eeb6e493e2e2f8bf8a66f5aa70f2f4ac96f9ca3/sentry_sdk-2.62.0.tar.gz", hash = "sha256:3c870b9f50d9fd15b58c817dbde1c7cfaa9fe3f05df0a4c6edd5571cb82f5491", size = 463986, upload-time = "2026-06-08T13:23:49.223Z" } +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/3d/07/05440381627877aae223fd68f330df9b9fc6641d08bf65328b55235617a2/sentry_sdk-2.62.0-py3-none-any.whl", hash = "sha256:27f61d13a86c3c1648dec666dd5a64f79772dd6a84b446f11866601ecab24f6f", size = 490586, upload-time = "2026-06-08T13:23:47.486Z" }, + { 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]] name = "shellingham" version = "1.5.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, @@ -2188,7 +1681,7 @@ wheels = [ [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, @@ -2197,7 +1690,7 @@ wheels = [ [[package]] name = "sqlalchemy" version = "2.0.50" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -2252,7 +1745,7 @@ wheels = [ [[package]] name = "starlette" version = "1.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -2265,7 +1758,7 @@ wheels = [ [[package]] name = "strip-hints" version = "0.1.13" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "wheel" }, ] @@ -2284,10 +1777,25 @@ dependencies = [ { name = "cyclopts" }, { name = "httpx" }, { name = "platformdirs" }, + { name = "pydantic" }, { name = "pyyaml" }, + { name = "requests" }, ] [package.dev-dependencies] +codegen = [ + { name = "alembic" }, + { name = "bugsnag" }, + { name = "cloud-pipelines" }, + { 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" }, ] @@ -2299,16 +1807,31 @@ requires-dist = [ { name = "cyclopts", specifier = ">=4.16.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.32.0" }, ] [package.metadata.requires-dev] +codegen = [ + { name = "alembic", specifier = ">=1.18.4" }, + { name = "bugsnag", specifier = ">=4.9.0,<5" }, + { name = "cloud-pipelines", specifier = ">=0.23.2.4" }, + { 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" }] [[package]] name = "tomli" version = "2.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, @@ -2362,7 +1885,7 @@ wheels = [ [[package]] name = "typer" version = "0.26.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "annotated-doc" }, { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2377,7 +1900,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -2386,7 +1909,7 @@ wheels = [ [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -2398,7 +1921,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } 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/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, @@ -2407,7 +1930,7 @@ wheels = [ [[package]] name = "uvicorn" version = "0.49.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "click" }, { name = "h11" }, @@ -2432,7 +1955,7 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, @@ -2476,7 +1999,7 @@ wheels = [ [[package]] name = "watchfiles" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "anyio" }, ] @@ -2590,10 +2113,22 @@ wheels = [ { 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://pkgs.shopify.io/basic/data/python/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]] name = "websocket-client" version = "1.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, @@ -2602,7 +2137,7 @@ wheels = [ [[package]] name = "websockets" version = "16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, @@ -2670,7 +2205,7 @@ wheels = [ [[package]] name = "wheel" version = "0.47.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } dependencies = [ { name = "packaging" }, ] @@ -2682,7 +2217,7 @@ wheels = [ [[package]] name = "wrapt" version = "2.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pkgs.shopify.io/basic/data/python/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" }, @@ -2764,119 +2299,3 @@ wheels = [ { 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" }, ] - -[[package]] -name = "yarl" -version = "1.24.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, - { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, - { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, - { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, - { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, - { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, - { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, - { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, - { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, - { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, - { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, - { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, - { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, - { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, - { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, - { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, - { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, - { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, - { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, -] From ec1da8ca79d677cf4c32867729a5ae6a37591071 Mon Sep 17 00:00:00 2001 From: Volv G Date: Wed, 10 Jun 2026 20:39:37 -0700 Subject: [PATCH 006/111] fix: follow published component successor digests --- tangle_cli/client.py | 36 +++++++--- tests/test_static_client.py | 133 +++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 12 deletions(-) diff --git a/tangle_cli/client.py b/tangle_cli/client.py index 3389914..214b53e 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -309,17 +309,31 @@ def get_component_spec(self, digest: str) -> ComponentSpec: return self.get_component(digest) def resolve_digest(self, digest: str) -> str: - """Resolve a component identifier to a digest when possible.""" - - if digest.startswith("sha256:"): - return digest - matches = self.list_published_components(digest=digest) - if len(matches) == 1 and matches[0].get("digest"): - return str(matches[0]["digest"]) - matches = self.list_published_components(name_substring=digest) - if len(matches) == 1 and matches[0].get("digest"): - return str(matches[0]["digest"]) - return digest + """Resolve a component digest/name, following deprecation successors.""" + + current = digest + seen: set[str] = set() + + while current not in seen: + seen.add(current) + matches = self.list_published_components(include_deprecated=True, digest=current) + if not matches: + matches = self.list_published_components( + 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 list_published_components( self, diff --git a/tests/test_static_client.py b/tests/test_static_client.py index de28dfa..9b1a32f 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -6,7 +6,7 @@ import requests from tangle_cli import TangleApiClient -from tangle_cli.generated.models import PipelineRunResponse +from tangle_cli.generated.models import PipelineRunResponse, PublishedComponentResponse from tangle_cli.models import PipelineRun, SecretInfo @@ -83,6 +83,137 @@ def test_secret_helpers_use_static_generated_endpoint_shapes() -> None: 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 list_published_components( + self, + include_deprecated: bool = False, + name_substring: str | None = None, + published_by_substring: str | None = None, + digest: str | None = None, + ) -> list[Any]: + self.lookups.append({ + "include_deprecated": include_deprecated, + "name_substring": name_substring, + "published_by_substring": published_by_substring, + "digest": digest, + }) + if digest is not None: + return self.by_digest.get(digest, []) + if name_substring is not None: + return self.by_name.get(name_substring, []) + return [] + + +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: From 44519c8d4e53972b64f7b1c29388d01e84b93ab0 Mon Sep 17 00:00:00 2001 From: Volv G Date: Wed, 10 Jun 2026 21:26:50 -0700 Subject: [PATCH 007/111] feat: type generated operations and rename dynamic client --- tangle_cli/__init__.py | 4 +- tangle_cli/client.py | 43 +++++++- tangle_cli/component_inspector.py | 4 +- ..._client.py => dynamic_discovery_client.py} | 18 ++-- tangle_cli/generated/operations.py | 72 ++++++------- tangle_cli/openapi/codegen.py | 86 ++++++++++++++- tangle_cli/published_components_cli.py | 8 +- tests/test_codegen.py | 101 ++++++++++++++++++ ...nt.py => test_dynamic_discovery_client.py} | 24 ++--- tests/test_static_client.py | 29 +++++ tests/test_tangle_deploy_compat_imports.py | 6 +- 11 files changed, 320 insertions(+), 75 deletions(-) rename tangle_cli/{api_client.py => dynamic_discovery_client.py} (94%) rename tests/{test_api_client.py => test_dynamic_discovery_client.py} (91%) diff --git a/tangle_cli/__init__.py b/tangle_cli/__init__.py index eeb01f1..72fc5bd 100644 --- a/tangle_cli/__init__.py +++ b/tangle_cli/__init__.py @@ -1,6 +1,6 @@ """tangle-cli public API.""" -from tangle_cli.api_client import TangleOpenApiClient +from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient from tangle_cli.client import TangleApiClient -__all__ = ["TangleApiClient", "TangleOpenApiClient"] +__all__ = ["TangleApiClient", "TangleDynamicDiscoveryClient"] diff --git a/tangle_cli/client.py b/tangle_cli/client.py index 214b53e..5ebfaa2 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -8,7 +8,7 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from dataclasses import asdict, is_dataclass from typing import Any from urllib.parse import quote, urljoin @@ -39,11 +39,21 @@ ) +def migrate_to(native_method: str) -> Callable[[Any], Any]: + """Attach a machine-readable hint for compatibility wrapper migrations.""" + + def decorator(method: Any) -> Any: + method.__tangle_migrate_to__ = native_method + return method + + return decorator + + class TangleApiClient(GeneratedOperationsMixin): """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 OpenAPI client. No + accepting the auth/header knobs used by the dynamic-discovery client. No OpenAPI schema is loaded at runtime; all endpoint wrappers are checked in. """ @@ -150,6 +160,11 @@ def _request_json( 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]: @@ -197,9 +212,11 @@ def _decode_response(response: requests.Response) -> Any: # ---- Compatibility helpers consumed by tangle-deploy ----------------- + @migrate_to("artifacts_get") def get_artifact(self, artifact_id: str) -> ArtifactInfo: return ArtifactInfo.from_dict(_to_plain(self.artifacts_get(artifact_id))) + @migrate_to("artifacts_signed_artifact_url") def get_artifact_signed_url(self, artifact_id: str) -> str | dict[str, Any] | None: data = _to_plain(self.artifacts_signed_artifact_url(artifact_id)) return data.get("signed_url") if isinstance(data, dict) else data @@ -209,18 +226,23 @@ def get_execution_details(self, execution_id: str) -> ExecutionDetails: self._enrich_execution_tree(details) return details + @migrate_to("executions_graph_execution_state") def get_execution_graph_state(self, execution_id: str) -> GraphExecutionState: return GraphExecutionState.from_dict(_to_plain(self.executions_graph_execution_state(execution_id))) + @migrate_to("executions_state") def get_execution_graph_state_alt(self, execution_id: str) -> GraphExecutionState: return GraphExecutionState.from_dict(_to_plain(self.executions_state(execution_id))) + @migrate_to("executions_container_state") def get_execution_container_state(self, execution_id: str) -> ContainerState: return ContainerState.from_dict(_to_plain(self.executions_container_state(execution_id))) + @migrate_to("executions_artifacts") def get_execution_artifacts(self, execution_id: str) -> dict[str, Any]: return _to_plain(self.executions_artifacts(execution_id)) + @migrate_to("executions_container_log") def get_execution_container_log(self, execution_id: str) -> str | dict[str, Any] | None: data = _to_plain(self.executions_container_log(execution_id)) if isinstance(data, dict) and "log_text" in data: @@ -239,6 +261,7 @@ def stream_execution_container_log(self, execution_id: str) -> requests.Response response.raise_for_status() return response + @migrate_to("pipeline_runs_list") def list_pipeline_runs( self, page_token: str | None = None, @@ -257,6 +280,7 @@ def list_pipeline_runs( ) ) + @migrate_to("pipeline_runs_create") def create_pipeline_run( self, root_task: Any, @@ -272,17 +296,21 @@ def create_pipeline_run( self._request_json("POST", "/api/pipeline_runs/", json_data=self._clean_mapping(body)) ) + @migrate_to("pipeline_runs_get") def get_pipeline_run(self, run_id: str) -> PipelineRun: return PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) + @migrate_to("pipeline_runs_cancel") def cancel_pipeline_run(self, run_id: str) -> None: self.pipeline_runs_cancel(run_id) return None + @migrate_to("pipeline_runs_annotations") def list_pipeline_run_annotations(self, run_id: str) -> dict[str, str | None]: data = self.pipeline_runs_annotations(run_id) return data if isinstance(data, dict) else {} + @migrate_to("pipeline_runs_put_annotations") def set_pipeline_run_annotation( self, run_id: str, @@ -292,19 +320,23 @@ def set_pipeline_run_annotation( self.pipeline_runs_put_annotations(run_id, key, value=value) return None + @migrate_to("pipeline_runs_delete_annotations") def delete_pipeline_run_annotation(self, run_id: str, key: str) -> None: self.pipeline_runs_delete_annotations(run_id, key) return None + @migrate_to("users_me") def get_current_user(self) -> UserInfo | None: data = _to_plain(self.users_me()) if data is None: return None return UserInfo(id=data.get("id"), permissions=data.get("permissions", [])) + @migrate_to("components_get") def get_component(self, digest: str) -> ComponentSpec: return ComponentSpec.from_dict(_to_plain(self.components_get(digest))) + @migrate_to("components_get") def get_component_spec(self, digest: str) -> ComponentSpec: return self.get_component(digest) @@ -335,6 +367,7 @@ def resolve_digest(self, digest: str) -> str: return current + @migrate_to("published_components_list") def list_published_components( self, include_deprecated: bool = False, @@ -426,6 +459,7 @@ def find_existing_components( _index_component_info(found, info) return found + @migrate_to("published_components_create") def publish_component(self, component_reference: dict[str, Any]) -> dict[str, Any]: return _to_plain( self._request_json( @@ -435,6 +469,7 @@ def publish_component(self, component_reference: dict[str, Any]) -> dict[str, An ) ) + @migrate_to("published_components_update") def update_published_component( self, digest: str, @@ -496,11 +531,13 @@ def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None: details = self.get_run_details(run_id, include_implementations=True) return details.execution.task_spec if details.execution else None + @migrate_to("secrets_list") def list_secrets(self) -> list[SecretInfo]: data = _to_plain(self.secrets_list()) rows = data.get("secrets", []) if isinstance(data, dict) else data or [] return [SecretInfo.from_dict(row) for row in rows] + @migrate_to("secrets_create") def create_secret( self, secret_name: str, @@ -519,6 +556,7 @@ def create_secret( ) ) + @migrate_to("secrets_update") def update_secret( self, secret_name: str, @@ -537,6 +575,7 @@ def update_secret( ) ) + @migrate_to("secrets_delete") def delete_secret(self, secret_name: str) -> None: self.secrets_delete(secret_name) return None diff --git a/tangle_cli/component_inspector.py b/tangle_cli/component_inspector.py index 40c6f6a..458ab0d 100644 --- a/tangle_cli/component_inspector.py +++ b/tangle_cli/component_inspector.py @@ -19,7 +19,7 @@ class ComponentApiClient(Protocol): - """Small subset of :class:`tangle_cli.api_client.TangleOpenApiClient` used here.""" + """Small subset of :class:`tangle_cli.dynamic_discovery_client.TangleDynamicDiscoveryClient` used here.""" base_url: str @@ -27,7 +27,7 @@ def call(self, operation_name: str, **params: Any) -> Any: ... def _request_path(client: ComponentApiClient, path: str) -> httpx.Response: - """Fetch an API-origin path using a dynamic OpenAPI client's auth settings. + """Fetch an API-origin path using a dynamic-discovery 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 diff --git a/tangle_cli/api_client.py b/tangle_cli/dynamic_discovery_client.py similarity index 94% rename from tangle_cli/api_client.py rename to tangle_cli/dynamic_discovery_client.py index 795bc61..d3a5582 100644 --- a/tangle_cli/api_client.py +++ b/tangle_cli/dynamic_discovery_client.py @@ -1,4 +1,4 @@ -"""Programmatic dynamic OpenAPI client for Tangle backends.""" +"""Programmatic dynamic-discovery client for Tangle backends.""" from __future__ import annotations @@ -24,8 +24,8 @@ ) -class TangleOpenApiClient: - """Dynamic client generated from a Tangle OpenAPI schema. +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 @@ -66,7 +66,7 @@ def from_schema( auth_header: str | None = None, header: list[str] | str | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> TangleOpenApiClient: + ) -> TangleDynamicDiscoveryClient: """Create a client from an already loaded OpenAPI schema.""" return cls( @@ -89,7 +89,7 @@ def from_cache( auth_header: str | None = None, header: list[str] | str | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> TangleOpenApiClient: + ) -> TangleDynamicDiscoveryClient: """Create a client from the local schema cache without network access.""" normalized_base_url = _normalize_base_url(base_url or default_base_url()) @@ -97,7 +97,7 @@ def from_cache( if schema is None: raise FileNotFoundError( f"No cached OpenAPI schema for {normalized_base_url}; " - "call TangleOpenApiClient.from_cache_or_refresh(...) or run `tangle api refresh`." + "call TangleDynamicDiscoveryClient.from_cache_or_refresh(...) or run `tangle api refresh`." ) return cls.from_schema( schema, @@ -119,7 +119,7 @@ def from_url( auth_header: str | None = None, header: list[str] | str | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> TangleOpenApiClient: + ) -> TangleDynamicDiscoveryClient: """Fetch ``/openapi.json`` and create a client without writing the cache.""" normalized_base_url = _normalize_base_url(base_url or default_base_url()) @@ -150,7 +150,7 @@ def from_cache_or_refresh( auth_header: str | None = None, header: list[str] | str | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> TangleOpenApiClient: + ) -> 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()) @@ -251,7 +251,7 @@ def _build_groups( class _OperationGroup: """Dynamic operation namespace returned by ``client.``.""" - def __init__(self, client: TangleOpenApiClient, group_name: str) -> None: + def __init__(self, client: TangleDynamicDiscoveryClient, group_name: str) -> None: self._client = client self._group_name = group_name diff --git a/tangle_cli/generated/operations.py b/tangle_cli/generated/operations.py index 5fccd82..35e30be 100644 --- a/tangle_cli/generated/operations.py +++ b/tangle_cli/generated/operations.py @@ -13,7 +13,7 @@ class GeneratedOperationsMixin: """Mixin containing one checked-in method per OpenAPI operation.""" - def admin_execution_node_status(self, id: Any, status: Any) -> Any: + def admin_execution_node_status(self, id: Any, status: Any) -> None: return self._request_json( 'PUT', '/api/admin/execution_node/{id}/status', @@ -23,7 +23,7 @@ def admin_execution_node_status(self, id: Any, status: Any) -> Any: response_model=None, ) - def admin_set_read_only_model(self, read_only: Any) -> Any: + def admin_set_read_only_model(self, read_only: Any) -> None: return self._request_json( 'PUT', '/api/admin/set_read_only_model', @@ -33,7 +33,7 @@ def admin_set_read_only_model(self, read_only: Any) -> Any: response_model=None, ) - def admin_sql_engine_connection_pool_status(self) -> Any: + def admin_sql_engine_connection_pool_status(self) -> str: return self._request_json( 'GET', '/api/admin/sql_engine_connection_pool_status', @@ -43,7 +43,7 @@ def admin_sql_engine_connection_pool_status(self) -> Any: response_model=None, ) - def artifacts_get(self, id: Any) -> Any: + def artifacts_get(self, id: Any) -> GetArtifactInfoResponse: return self._request_json( 'GET', '/api/artifacts/{id}', @@ -53,7 +53,7 @@ def artifacts_get(self, id: Any) -> Any: response_model=GetArtifactInfoResponse, ) - def artifacts_signed_artifact_url(self, id: Any) -> Any: + def artifacts_signed_artifact_url(self, id: Any) -> GetArtifactSignedUrlResponse: return self._request_json( 'GET', '/api/artifacts/{id}/signed_artifact_url', @@ -63,7 +63,7 @@ def artifacts_signed_artifact_url(self, id: Any) -> Any: response_model=GetArtifactSignedUrlResponse, ) - def component_libraries_list(self, name_substring: Any = None) -> Any: + def component_libraries_list(self, name_substring: Any = None) -> ListComponentLibrariesResponse: return self._request_json( 'GET', '/api/component_libraries/', @@ -73,7 +73,7 @@ def component_libraries_list(self, name_substring: Any = None) -> Any: response_model=ListComponentLibrariesResponse, ) - def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> Any: + def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: return self._request_json( 'POST', '/api/component_libraries/', @@ -83,7 +83,7 @@ def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> response_model=ComponentLibraryResponse, ) - def component_libraries_get(self, id: Any, include_component_texts: Any = None) -> Any: + def component_libraries_get(self, id: Any, include_component_texts: Any = None) -> ComponentLibraryResponse: return self._request_json( 'GET', '/api/component_libraries/{id}', @@ -93,7 +93,7 @@ def component_libraries_get(self, id: Any, include_component_texts: Any = None) response_model=ComponentLibraryResponse, ) - def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> Any: + def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: return self._request_json( 'PUT', '/api/component_libraries/{id}', @@ -103,7 +103,7 @@ def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = response_model=ComponentLibraryResponse, ) - def component_library_pins_me(self) -> Any: + def component_library_pins_me(self) -> UserComponentLibraryPinsResponse: return self._request_json( 'GET', '/api/component_library_pins/me/', @@ -113,7 +113,7 @@ def component_library_pins_me(self) -> Any: response_model=UserComponentLibraryPinsResponse, ) - def component_library_pins_put_me(self, body: Any = None) -> Any: + def component_library_pins_put_me(self, body: Any = None) -> None: return self._request_json( 'PUT', '/api/component_library_pins/me/', @@ -123,7 +123,7 @@ def component_library_pins_put_me(self, body: Any = None) -> Any: response_model=None, ) - def components_get(self, digest: Any) -> Any: + def components_get(self, digest: Any) -> ComponentResponse: return self._request_json( 'GET', '/api/components/{digest}', @@ -133,7 +133,7 @@ def components_get(self, digest: Any) -> Any: response_model=ComponentResponse, ) - def executions_artifacts(self, id: Any) -> Any: + def executions_artifacts(self, id: Any) -> GetExecutionArtifactsResponse: return self._request_json( 'GET', '/api/executions/{id}/artifacts', @@ -143,7 +143,7 @@ def executions_artifacts(self, id: Any) -> Any: response_model=GetExecutionArtifactsResponse, ) - def executions_container_log(self, id: Any) -> Any: + def executions_container_log(self, id: Any) -> GetContainerExecutionLogResponse: return self._request_json( 'GET', '/api/executions/{id}/container_log', @@ -153,7 +153,7 @@ def executions_container_log(self, id: Any) -> Any: response_model=GetContainerExecutionLogResponse, ) - def executions_container_state(self, id: Any, include_execution_nodes_linked_to_same_container_execution: Any = None) -> Any: + 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', @@ -163,7 +163,7 @@ def executions_container_state(self, id: Any, include_execution_nodes_linked_to_ response_model=GetContainerExecutionStateResponse, ) - def executions_details(self, id: Any) -> Any: + def executions_details(self, id: Any) -> GetExecutionInfoResponse: return self._request_json( 'GET', '/api/executions/{id}/details', @@ -173,7 +173,7 @@ def executions_details(self, id: Any) -> Any: response_model=GetExecutionInfoResponse, ) - def executions_graph_execution_state(self, id: Any) -> Any: + def executions_graph_execution_state(self, id: Any) -> GetGraphExecutionStateResponse: return self._request_json( 'GET', '/api/executions/{id}/graph_execution_state', @@ -183,7 +183,7 @@ def executions_graph_execution_state(self, id: Any) -> Any: response_model=GetGraphExecutionStateResponse, ) - def executions_state(self, id: Any) -> Any: + def executions_state(self, id: Any) -> GetGraphExecutionStateResponse: return self._request_json( 'GET', '/api/executions/{id}/state', @@ -193,7 +193,7 @@ def executions_state(self, id: Any) -> Any: 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) -> Any: + 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/', @@ -203,7 +203,7 @@ def pipeline_runs_list(self, page_token: Any = None, filter: Any = None, filter_ response_model=ListPipelineJobsResponse, ) - def pipeline_runs_create(self, body: Any = None) -> Any: + def pipeline_runs_create(self, body: Any = None) -> PipelineRunResponse: return self._request_json( 'POST', '/api/pipeline_runs/', @@ -213,7 +213,7 @@ def pipeline_runs_create(self, body: Any = None) -> Any: response_model=PipelineRunResponse, ) - def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> Any: + def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> PipelineRunResponse: return self._request_json( 'GET', '/api/pipeline_runs/{id}', @@ -223,7 +223,7 @@ def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> Any response_model=PipelineRunResponse, ) - def pipeline_runs_annotations(self, id: Any) -> Any: + def pipeline_runs_annotations(self, id: Any) -> dict[str, Any]: return self._request_json( 'GET', '/api/pipeline_runs/{id}/annotations/', @@ -233,7 +233,7 @@ def pipeline_runs_annotations(self, id: Any) -> Any: response_model=None, ) - def pipeline_runs_put_annotations(self, id: Any, key: Any, value: Any = None) -> Any: + 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}', @@ -243,7 +243,7 @@ def pipeline_runs_put_annotations(self, id: Any, key: Any, value: Any = None) -> response_model=None, ) - def pipeline_runs_delete_annotations(self, id: Any, key: Any) -> Any: + def pipeline_runs_delete_annotations(self, id: Any, key: Any) -> None: return self._request_json( 'DELETE', '/api/pipeline_runs/{id}/annotations/{key}', @@ -253,7 +253,7 @@ def pipeline_runs_delete_annotations(self, id: Any, key: Any) -> Any: response_model=None, ) - def pipeline_runs_cancel(self, id: Any) -> Any: + def pipeline_runs_cancel(self, id: Any) -> None: return self._request_json( 'POST', '/api/pipeline_runs/{id}/cancel', @@ -263,7 +263,7 @@ def pipeline_runs_cancel(self, id: Any) -> Any: response_model=None, ) - def published_components_list(self, include_deprecated: Any = None, name_substring: Any = None, published_by_substring: Any = None, digest: Any = None) -> Any: + 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/', @@ -273,7 +273,7 @@ def published_components_list(self, include_deprecated: Any = None, name_substri response_model=ListPublishedComponentsResponse, ) - def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None) -> Any: + 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/', @@ -283,7 +283,7 @@ def published_components_create(self, digest: Any = None, name: Any = None, tag: response_model=PublishedComponentResponse, ) - def published_components_update(self, digest: Any, deprecated: Any = None, superseded_by: Any = None) -> Any: + def published_components_update(self, digest: Any, deprecated: Any = None, superseded_by: Any = None) -> PublishedComponentResponse: return self._request_json( 'PUT', '/api/published_components/{digest}', @@ -293,7 +293,7 @@ def published_components_update(self, digest: Any, deprecated: Any = None, super response_model=PublishedComponentResponse, ) - def secrets_list(self) -> Any: + def secrets_list(self) -> ListSecretsResponse: return self._request_json( 'GET', '/api/secrets/', @@ -303,7 +303,7 @@ def secrets_list(self) -> Any: response_model=ListSecretsResponse, ) - def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> Any: + 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/', @@ -313,7 +313,7 @@ def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = response_model=SecretInfoResponse, ) - def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> Any: + 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}', @@ -323,7 +323,7 @@ def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = response_model=SecretInfoResponse, ) - def secrets_delete(self, secret_name: Any) -> Any: + def secrets_delete(self, secret_name: Any) -> None: return self._request_json( 'DELETE', '/api/secrets/{secret_name}', @@ -333,7 +333,7 @@ def secrets_delete(self, secret_name: Any) -> Any: response_model=None, ) - def users_me(self) -> Any: + def users_me(self) -> GetUserResponse | None: return self._request_json( 'GET', '/api/users/me', @@ -343,7 +343,7 @@ def users_me(self) -> Any: response_model=GetUserResponse, ) - def users_me_settings(self, setting_names: Any = None) -> Any: + def users_me_settings(self, setting_names: Any = None) -> UserSettingsResponse: return self._request_json( 'GET', '/api/users/me/settings', @@ -353,7 +353,7 @@ def users_me_settings(self, setting_names: Any = None) -> Any: response_model=UserSettingsResponse, ) - def users_patch_me_settings(self, body: Any = None) -> Any: + def users_patch_me_settings(self, body: Any = None) -> None: return self._request_json( 'PATCH', '/api/users/me/settings', @@ -363,7 +363,7 @@ def users_patch_me_settings(self, body: Any = None) -> Any: response_model=None, ) - def users_delete_me_settings(self, setting_names: Any) -> Any: + def users_delete_me_settings(self, setting_names: Any) -> None: return self._request_json( 'DELETE', '/api/users/me/settings', diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py index e18b809..b5cddd7 100644 --- a/tangle_cli/openapi/codegen.py +++ b/tangle_cli/openapi/codegen.py @@ -64,7 +64,7 @@ def _schema_ref_name(schema: dict[str, Any] | None) -> str | None: return None -def _success_schema(operation: dict[str, Any]) -> dict[str, Any] | 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) @@ -72,7 +72,12 @@ def _success_schema(operation: dict[str, Any]) -> dict[str, Any] | None: break else: response = next(iter(responses.values()), None) - if not isinstance(response, dict): + 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()), {}) @@ -80,8 +85,78 @@ def _success_schema(operation: dict[str, Any]) -> dict[str, Any] | 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]) -> str | None: - return _schema_ref_name(_success_schema(operation)) + 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) + return _schema_ref_name(schema) + + +def _response_return_annotation(operation: dict[str, Any]) -> 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) + + +def _schema_return_annotation(schema: dict[str, Any]) -> str: + ref_name = _schema_ref_name(schema) + 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) + 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 generate_models(schema: dict[str, Any]) -> str: @@ -230,10 +305,11 @@ def generate_operations(schema: dict[str, Any]) -> str: ) response_model = _response_model_name(operation.operation) response_arg = response_model if response_model else "None" + response_annotation = _response_return_annotation(operation.operation) if signature: - def_line = f" def {method_name}(self, {signature}) -> Any:" + def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:" else: - def_line = f" def {method_name}(self) -> Any:" + def_line = f" def {method_name}(self) -> {response_annotation}:" lines.extend([ def_line, f" return self._request_json(", diff --git a/tangle_cli/published_components_cli.py b/tangle_cli/published_components_cli.py index b955c0c..1c4bbce 100644 --- a/tangle_cli/published_components_cli.py +++ b/tangle_cli/published_components_cli.py @@ -7,7 +7,7 @@ from cyclopts import App, Parameter -from .api_client import TangleOpenApiClient +from .dynamic_discovery_client import TangleDynamicDiscoveryClient from .api_transport import DEFAULT_TIMEOUT_SECONDS from .component_inspector import ( get_standard_library, @@ -54,10 +54,10 @@ def _client_from_options( token: str | None = None, auth_header: str | None = None, header: list[str] | None = None, -) -> TangleOpenApiClient: - """Create the dynamic OpenAPI client used by published-component commands.""" +) -> TangleDynamicDiscoveryClient: + """Create the dynamic-discovery client used by published-component commands.""" - return TangleOpenApiClient.from_cache_or_refresh( + return TangleDynamicDiscoveryClient.from_cache_or_refresh( base_url=base_url, token=token, auth_header=auth_header, diff --git a/tests/test_codegen.py b/tests/test_codegen.py index b6cd959..db33f78 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -225,3 +225,104 @@ def test_generate_writes_support_modules_to_custom_out(tmp_path) -> None: assert "class GeneratedOperationsMixin" in operations assert "def published_components_list" in operations assert "name_substring" in operations + + +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 .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_api_client.py b/tests/test_dynamic_discovery_client.py similarity index 91% rename from tests/test_api_client.py rename to tests/test_dynamic_discovery_client.py index 866eab7..42bb3f1 100644 --- a/tests/test_api_client.py +++ b/tests/test_dynamic_discovery_client.py @@ -6,7 +6,7 @@ import pytest from tangle_cli import api_schema -from tangle_cli.api_client import TangleOpenApiClient +from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient SCHEMA = { @@ -125,7 +125,7 @@ def fake_get(url, **kwargs): monkeypatch.setattr(httpx, "get", fake_get) api_schema.write_cached_schema(SCHEMA, "https://api.test") - client = TangleOpenApiClient.from_cache(base_url="https://api.test") + client = TangleDynamicDiscoveryClient.from_cache(base_url="https://api.test") assert client.operations == ( "components.get", @@ -146,10 +146,10 @@ def fake_get(url, **kwargs): monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(httpx, "get", fake_get) - first = TangleOpenApiClient.from_cache_or_refresh( + first = TangleDynamicDiscoveryClient.from_cache_or_refresh( base_url="https://api.test/", headers={"Cloud-Auth": "cloud-token"} ) - second = TangleOpenApiClient.from_cache_or_refresh(base_url="https://api.test") + second = TangleDynamicDiscoveryClient.from_cache_or_refresh(base_url="https://api.test") assert first.operations == second.operations assert len(gets) == 1 @@ -165,7 +165,7 @@ def fake_request(method, url, **kwargs): return json_response(method, url, {"url": url}) monkeypatch.setattr(httpx, "request", fake_request) - client = TangleOpenApiClient.from_schema(SCHEMA, base_url="https://api.test") + 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"} @@ -191,7 +191,7 @@ def fake_request(method, url, **kwargs): return json_response(method, url, {"ok": True}) monkeypatch.setattr(httpx, "request", fake_request) - client = TangleOpenApiClient.from_schema(SCHEMA, base_url="https://api.test") + 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") @@ -208,7 +208,7 @@ def fake_request(method, url, **kwargs): return json_response(method, url, {"ok": True}) monkeypatch.setattr(httpx, "request", fake_request) - client = TangleOpenApiClient.from_schema(SCHEMA, base_url="https://api.test") + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") client.call("published-components.create", name="demo", labels=["stable"]) @@ -230,7 +230,7 @@ def fake_request(method, url, **kwargs): secret_path = tmp_path / "secret.json" secret_path.write_text('{"token":"secret"}', encoding="utf-8") monkeypatch.setattr(httpx, "request", fake_request) - client = TangleOpenApiClient.from_schema(SCHEMA, base_url="https://api.test") + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") client.call("published-components.create", body=f"@{secret_path}") @@ -248,7 +248,7 @@ def fake_request(method, url, **kwargs): monkeypatch.setenv("TANGLE_API_AUTH_HEADER", "Basic env-auth") monkeypatch.setattr(httpx, "request", fake_request) - client = TangleOpenApiClient.from_schema( + client = TangleDynamicDiscoveryClient.from_schema( SCHEMA, base_url="https://api.test", token="bearer-token", @@ -273,7 +273,7 @@ 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 = TangleOpenApiClient.from_schema(SCHEMA, base_url="https://api.test") + client = TangleDynamicDiscoveryClient.from_schema(SCHEMA, base_url="https://api.test") with pytest.raises(httpx.HTTPStatusError) as exc_info: client.call("components.get", digest="abc") @@ -300,8 +300,8 @@ def fake_get(url, **kwargs): monkeypatch.setattr(httpx, "get", fake_get) monkeypatch.setattr(sys, "argv", ["tangle", "api", "components", "get", "abc"]) - import tangle_cli.api_client as api_client + import tangle_cli.dynamic_discovery_client as dynamic_discovery_client - importlib.reload(api_client) + importlib.reload(dynamic_discovery_client) assert calls == [] diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 9b1a32f..fbbe77e 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -48,6 +48,19 @@ def test_public_static_client_import_and_generated_operation() -> None: assert session.calls[0]["url"] == "https://api.test/api/pipeline_runs/run%2F1" +def test_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_compat_get_pipeline_run_returns_dataclass_with_dict_helpers() -> None: session = FakeSession([ response({"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}) @@ -62,6 +75,22 @@ def test_compat_get_pipeline_run_returns_dataclass_with_dict_helpers() -> None: assert run["root_execution_id"] == "exec-1" +def test_compat_wrapper_migration_hints_are_machine_readable() -> None: + assert TangleApiClient.get_execution_container_state.__tangle_migrate_to__ == ( + "executions_container_state" + ) + assert TangleApiClient.get_pipeline_run.__tangle_migrate_to__ == "pipeline_runs_get" + assert TangleApiClient.list_published_components.__tangle_migrate_to__ == ( + "published_components_list" + ) + assert TangleApiClient.create_secret.__tangle_migrate_to__ == "secrets_create" + + assert not hasattr(TangleApiClient.resolve_digest, "__tangle_migrate_to__") + assert not hasattr(TangleApiClient.get_run_details, "__tangle_migrate_to__") + assert not hasattr(TangleApiClient.find_existing_components, "__tangle_migrate_to__") + assert not hasattr(TangleApiClient._enrich_execution_tree, "__tangle_migrate_to__") + + def test_secret_helpers_use_static_generated_endpoint_shapes() -> None: session = FakeSession([ response({ diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index d8e2765..3e73c86 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -7,8 +7,8 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: import tangle_cli - from tangle_cli import TangleApiClient, TangleOpenApiClient, utils as utils_module - from tangle_cli.api_client import TangleOpenApiClient as OpenApiClient + from tangle_cli import TangleApiClient, 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.component_inspector import ( get_standard_library, @@ -87,7 +87,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: traverse_pipeline_tasks, ) - assert TangleOpenApiClient.__name__ == OpenApiClient.__name__ == "TangleOpenApiClient" + assert TangleDynamicDiscoveryClient.__name__ == DynamicDiscoveryClient.__name__ == "TangleDynamicDiscoveryClient" assert TangleApiClient.__name__ == StaticClient.__name__ == "TangleApiClient" assert hasattr(tangle_cli, "TangleApiClient") assert importlib.util.find_spec("tangle_cli.client") is not None From 1242b19e72f8b5ab5bdd1fde9b67e3e0422db861 Mon Sep 17 00:00:00 2001 From: Volv G Date: Wed, 10 Jun 2026 22:48:32 -0700 Subject: [PATCH 008/111] refactor: remove static client compatibility wrappers --- tangle_cli/client.py | 222 +++--------------------------------- tests/test_static_client.py | 123 ++++++++++++++------ 2 files changed, 105 insertions(+), 240 deletions(-) diff --git a/tangle_cli/client.py b/tangle_cli/client.py index 5ebfaa2..9397ba7 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -3,12 +3,12 @@ ``TangleApiClient`` is the stable wrapper class consumed by downstream tools. Endpoint methods are generated offline into :mod:`tangle_cli.generated.operations` from the checked-in OpenAPI snapshot; handwritten methods in this file keep the -higher-level compatibility helpers that downstream callers use. +higher-level semantic helpers that downstream callers use. """ from __future__ import annotations -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Iterable, Mapping from dataclasses import asdict, is_dataclass from typing import Any from urllib.parse import quote, urljoin @@ -25,30 +25,16 @@ from .generated.operations import GeneratedOperationsMixin from .logger import Logger, _null_logger from .models import ( - ArtifactInfo, ComponentInfo, ComponentSpec, - ContainerState, ExecutionDetails, GraphExecutionState, PipelineRun, RunDetails, - SecretInfo, TaskSpec, - UserInfo, ) -def migrate_to(native_method: str) -> Callable[[Any], Any]: - """Attach a machine-readable hint for compatibility wrapper migrations.""" - - def decorator(method: Any) -> Any: - method.__tangle_migrate_to__ = native_method - return method - - return decorator - - class TangleApiClient(GeneratedOperationsMixin): """Single public API wrapper for Tangle backends. @@ -210,45 +196,13 @@ def _decode_response(response: requests.Response) -> Any: except ValueError: return response.text - # ---- Compatibility helpers consumed by tangle-deploy ----------------- - - @migrate_to("artifacts_get") - def get_artifact(self, artifact_id: str) -> ArtifactInfo: - return ArtifactInfo.from_dict(_to_plain(self.artifacts_get(artifact_id))) - - @migrate_to("artifacts_signed_artifact_url") - def get_artifact_signed_url(self, artifact_id: str) -> str | dict[str, Any] | None: - data = _to_plain(self.artifacts_signed_artifact_url(artifact_id)) - return data.get("signed_url") if isinstance(data, dict) else data + # ---- Handwritten semantic helpers consumed by tangle-deploy ---------- def get_execution_details(self, execution_id: str) -> ExecutionDetails: details = ExecutionDetails.from_dict(_to_plain(self.executions_details(execution_id))) self._enrich_execution_tree(details) return details - @migrate_to("executions_graph_execution_state") - def get_execution_graph_state(self, execution_id: str) -> GraphExecutionState: - return GraphExecutionState.from_dict(_to_plain(self.executions_graph_execution_state(execution_id))) - - @migrate_to("executions_state") - def get_execution_graph_state_alt(self, execution_id: str) -> GraphExecutionState: - return GraphExecutionState.from_dict(_to_plain(self.executions_state(execution_id))) - - @migrate_to("executions_container_state") - def get_execution_container_state(self, execution_id: str) -> ContainerState: - return ContainerState.from_dict(_to_plain(self.executions_container_state(execution_id))) - - @migrate_to("executions_artifacts") - def get_execution_artifacts(self, execution_id: str) -> dict[str, Any]: - return _to_plain(self.executions_artifacts(execution_id)) - - @migrate_to("executions_container_log") - def get_execution_container_log(self, execution_id: str) -> str | dict[str, Any] | None: - data = _to_plain(self.executions_container_log(execution_id)) - if isinstance(data, dict) and "log_text" in data: - return data.get("log_text") - return data - def stream_execution_container_log(self, execution_id: str) -> requests.Response: response = self._make_request( "GET", @@ -261,85 +215,11 @@ def stream_execution_container_log(self, execution_id: str) -> requests.Response response.raise_for_status() return response - @migrate_to("pipeline_runs_list") - def list_pipeline_runs( - self, - page_token: str | None = None, - filter: str | None = None, - filter_query: str | None = None, - include_pipeline_names: bool = False, - include_execution_stats: bool = False, - ) -> dict[str, Any]: - return _to_plain( - self.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, - ) - ) - - @migrate_to("pipeline_runs_create") - def create_pipeline_run( - self, - root_task: Any, - components: list[Any] | None = None, - annotations: dict[str, Any] | None = None, - ) -> PipelineRun: - body = { - "root_task": _to_plain(root_task), - "components": _to_plain(components), - "annotations": annotations, - } - return PipelineRun.from_dict( - self._request_json("POST", "/api/pipeline_runs/", json_data=self._clean_mapping(body)) - ) - - @migrate_to("pipeline_runs_get") - def get_pipeline_run(self, run_id: str) -> PipelineRun: - return PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) - - @migrate_to("pipeline_runs_cancel") - def cancel_pipeline_run(self, run_id: str) -> None: - self.pipeline_runs_cancel(run_id) - return None - - @migrate_to("pipeline_runs_annotations") - def list_pipeline_run_annotations(self, run_id: str) -> dict[str, str | None]: - data = self.pipeline_runs_annotations(run_id) - return data if isinstance(data, dict) else {} - - @migrate_to("pipeline_runs_put_annotations") - def set_pipeline_run_annotation( - self, - run_id: str, - key: str, - value: str | None = None, - ) -> None: - self.pipeline_runs_put_annotations(run_id, key, value=value) - return None - - @migrate_to("pipeline_runs_delete_annotations") - def delete_pipeline_run_annotation(self, run_id: str, key: str) -> None: - self.pipeline_runs_delete_annotations(run_id, key) - return None - - @migrate_to("users_me") - def get_current_user(self) -> UserInfo | None: - data = _to_plain(self.users_me()) - if data is None: - return None - return UserInfo(id=data.get("id"), permissions=data.get("permissions", [])) + def get_component_spec(self, digest: str) -> ComponentSpec: + """Return a parsed domain component spec from the generated component endpoint.""" - @migrate_to("components_get") - def get_component(self, digest: str) -> ComponentSpec: return ComponentSpec.from_dict(_to_plain(self.components_get(digest))) - @migrate_to("components_get") - def get_component_spec(self, digest: str) -> ComponentSpec: - return self.get_component(digest) - def resolve_digest(self, digest: str) -> str: """Resolve a component digest/name, following deprecation successors.""" @@ -348,9 +228,9 @@ def resolve_digest(self, digest: str) -> str: while current not in seen: seen.add(current) - matches = self.list_published_components(include_deprecated=True, digest=current) + matches = self._published_component_rows(include_deprecated=True, digest=current) if not matches: - matches = self.list_published_components( + matches = self._published_component_rows( include_deprecated=True, name_substring=current, ) @@ -367,8 +247,7 @@ def resolve_digest(self, digest: str) -> str: return current - @migrate_to("published_components_list") - def list_published_components( + def _published_component_rows( self, include_deprecated: bool = False, name_substring: str | None = None, @@ -398,7 +277,7 @@ def list_published_component_infos( ) -> list[ComponentInfo]: infos = [ ComponentInfo.from_dict(component) - for component in self.list_published_components( + for component in self._published_component_rows( include_deprecated=include_deprecated, name_substring=name_substring, published_by_substring=published_by_substring, @@ -459,31 +338,6 @@ def find_existing_components( _index_component_info(found, info) return found - @migrate_to("published_components_create") - def publish_component(self, component_reference: dict[str, Any]) -> dict[str, Any]: - return _to_plain( - self._request_json( - "POST", - "/api/published_components/", - json_data=_to_plain(component_reference), - ) - ) - - @migrate_to("published_components_update") - def update_published_component( - self, - digest: str, - deprecated: bool | None = None, - superseded_by: str | None = None, - ) -> dict[str, Any]: - return _to_plain( - self.published_components_update( - digest, - deprecated=deprecated, - superseded_by=superseded_by, - ) - ) - def get_component_search_schema(self) -> dict[str, Any]: return _to_plain( self._request_json( @@ -509,14 +363,17 @@ def get_run_details( include_execution_state: bool = False, execution_id: str | None = None, ) -> RunDetails: - run = self.get_pipeline_run(run_id) + run = PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) root_execution_id = execution_id or run.root_execution_id execution = self.get_execution_details(root_execution_id) if root_execution_id else None if execution and not include_implementations: execution.strip_implementations() - annotations = self.list_pipeline_run_annotations(run_id) if include_annotations else None + raw_annotations = self.pipeline_runs_annotations(run_id) if include_annotations else None + annotations = raw_annotations if isinstance(raw_annotations, dict) else None execution_state = ( - self.get_execution_graph_state(root_execution_id) + GraphExecutionState.from_dict( + _to_plain(self.executions_graph_execution_state(root_execution_id)) + ) if include_execution_state and root_execution_id else None ) @@ -531,55 +388,6 @@ def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None: details = self.get_run_details(run_id, include_implementations=True) return details.execution.task_spec if details.execution else None - @migrate_to("secrets_list") - def list_secrets(self) -> list[SecretInfo]: - data = _to_plain(self.secrets_list()) - rows = data.get("secrets", []) if isinstance(data, dict) else data or [] - return [SecretInfo.from_dict(row) for row in rows] - - @migrate_to("secrets_create") - def create_secret( - self, - secret_name: str, - secret_value: str, - description: str | None = None, - expires_at: str | None = None, - ) -> SecretInfo: - return SecretInfo.from_dict( - _to_plain( - self.secrets_create( - secret_name=secret_name, - secret_value=secret_value, - description=description, - expires_at=expires_at, - ) - ) - ) - - @migrate_to("secrets_update") - def update_secret( - self, - secret_name: str, - secret_value: str, - description: str | None = None, - expires_at: str | None = None, - ) -> SecretInfo: - return SecretInfo.from_dict( - _to_plain( - self.secrets_update( - secret_name=secret_name, - secret_value=secret_value, - description=description, - expires_at=expires_at, - ) - ) - ) - - @migrate_to("secrets_delete") - def delete_secret(self, secret_name: str) -> None: - self.secrets_delete(secret_name) - return None - def _enrich_execution_tree(self, execution: ExecutionDetails) -> None: child_ids = execution.raw.get("child_task_execution_ids") or {} if not isinstance(child_ids, dict): diff --git a/tests/test_static_client.py b/tests/test_static_client.py index fbbe77e..f8e8ed3 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -6,8 +6,12 @@ import requests from tangle_cli import TangleApiClient -from tangle_cli.generated.models import PipelineRunResponse, PublishedComponentResponse -from tangle_cli.models import PipelineRun, SecretInfo +from tangle_cli.generated.models import ( + ListPublishedComponentsResponse, + PipelineRunResponse, + PublishedComponentResponse, + SecretInfoResponse, +) def response(payload: Any = None, status_code: int = 200) -> requests.Response: @@ -61,37 +65,88 @@ def test_request_json_instantiates_list_response_models() -> None: assert runs[0].id == "run-1" -def test_compat_get_pipeline_run_returns_dataclass_with_dict_helpers() -> None: +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", + ] + 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", + "get_component_search_schema", + "search_components_v2", + ] + 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": "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) - run = client.get_pipeline_run("run-1") - - assert isinstance(run, PipelineRun) - assert run.id == "run-1" - assert run.get("created_by") == "alice" - assert run["root_execution_id"] == "exec-1" - - -def test_compat_wrapper_migration_hints_are_machine_readable() -> None: - assert TangleApiClient.get_execution_container_state.__tangle_migrate_to__ == ( - "executions_container_state" + details = client.get_run_details( + "run-1", + include_annotations=True, + include_execution_state=True, ) - assert TangleApiClient.get_pipeline_run.__tangle_migrate_to__ == "pipeline_runs_get" - assert TangleApiClient.list_published_components.__tangle_migrate_to__ == ( - "published_components_list" - ) - assert TangleApiClient.create_secret.__tangle_migrate_to__ == "secrets_create" - assert not hasattr(TangleApiClient.resolve_digest, "__tangle_migrate_to__") - assert not hasattr(TangleApiClient.get_run_details, "__tangle_migrate_to__") - assert not hasattr(TangleApiClient.find_existing_components, "__tangle_migrate_to__") - assert not hasattr(TangleApiClient._enrich_execution_tree, "__tangle_migrate_to__") + assert details.run.id == "run-1" + assert details.execution is not None + 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_secret_helpers_use_static_generated_endpoint_shapes() -> None: +def test_secret_native_operation_uses_static_generated_endpoint_shape() -> None: session = FakeSession([ response({ "secret_name": "demo", @@ -102,9 +157,9 @@ def test_secret_helpers_use_static_generated_endpoint_shapes() -> None: ]) client = TangleApiClient("https://api.test", session=session) - secret = client.create_secret("demo", "value", description="d") + secret = client.secrets_create("demo", "value", description="d") - assert isinstance(secret, SecretInfo) + 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/" @@ -123,13 +178,13 @@ def __init__( self.by_name = by_name or {} self.lookups: list[dict[str, Any]] = [] - def list_published_components( + def published_components_list( self, include_deprecated: bool = False, name_substring: str | None = None, published_by_substring: str | None = None, digest: str | None = None, - ) -> list[Any]: + ) -> ListPublishedComponentsResponse: self.lookups.append({ "include_deprecated": include_deprecated, "name_substring": name_substring, @@ -137,10 +192,12 @@ def list_published_components( "digest": digest, }) if digest is not None: - return self.by_digest.get(digest, []) - if name_substring is not None: - return self.by_name.get(name_substring, []) - return [] + 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_resolve_digest_returns_non_deprecated_digest() -> None: From 462b77441899eab92ae3b60de4ec8552d3d33233 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 06:01:51 -0700 Subject: [PATCH 009/111] refactor: remove custom search helpers from static client --- tangle_cli/client.py | 16 ---------------- tests/test_static_client.py | 4 ++-- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/tangle_cli/client.py b/tangle_cli/client.py index 9397ba7..92e3917 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -338,22 +338,6 @@ def find_existing_components( _index_component_info(found, info) return found - def get_component_search_schema(self) -> dict[str, Any]: - return _to_plain( - self._request_json( - "GET", - "/api/published_components/experimental/search/schema", - ) - ) - - def search_components_v2(self, *, body: dict[str, Any]) -> dict[str, Any]: - return _to_plain( - self._request_json( - "POST", - "/api/published_components/experimental/search", - json_data=body, - ) - ) def get_run_details( self, diff --git a/tests/test_static_client.py b/tests/test_static_client.py index f8e8ed3..b85f36f 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -90,6 +90,8 @@ def test_dumb_compat_wrappers_are_removed_but_semantic_helpers_remain() -> None: "create_secret", "update_secret", "delete_secret", + "get_component_search_schema", + "search_components_v2", ] for name in removed: assert not hasattr(TangleApiClient, name) @@ -104,8 +106,6 @@ def test_dumb_compat_wrappers_are_removed_but_semantic_helpers_remain() -> None: "list_published_component_infos", "get_component_spec", "stream_execution_container_log", - "get_component_search_schema", - "search_components_v2", ] for name in retained: assert hasattr(TangleApiClient, name) From e0f60983838a69611e9ad7dee8a4c97e64c282aa Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 10:20:50 -0700 Subject: [PATCH 010/111] feat: configure generated operations class name --- README.md | 9 ++- tangle_cli/client.py | 4 +- tangle_cli/generated/operations.py | 6 +- tangle_cli/openapi/codegen.py | 44 ++++++++++-- tests/test_codegen.py | 106 +++++++++++++++++++++++++++-- 5 files changed, 149 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 77034fd..69b71f1 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,9 @@ with `git submodule update --init --recursive`. `--out` controls where generated support modules are written. It defaults to `tangle_cli/generated`, which is the package support module used by the public -`tangle_cli/client.py` wrapper. Codegen writes exactly these support files: +`tangle_cli/client.py` wrapper. `--operations-class-name` controls the generated +operations class name in `/operations.py`; it defaults to +`GeneratedTangleApiOperations`. Codegen writes exactly these support files: ```text /__init__.py @@ -235,7 +237,10 @@ uv run python -m tangle_cli.openapi.codegen --openapi-url https://raw.githubuser Downstream tools can point `--out` at their own generated support package, e.g.: ```bash -uv run python -m tangle_cli.openapi.codegen --openapi-url https://oasis.shopify.io/openapi.json --out src/tangle_deploy/generated_api +uv run python -m tangle_cli.openapi.codegen \ + --openapi-url https://oasis.shopify.io/openapi.json \ + --out src/tangle_deploy/generated_api \ + --operations-class-name GeneratedTangleApiExtensions ``` At the time of writing the official repository does not commit that raw diff --git a/tangle_cli/client.py b/tangle_cli/client.py index 92e3917..d5cc777 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -22,7 +22,7 @@ default_base_url, default_token, ) -from .generated.operations import GeneratedOperationsMixin +from .generated.operations import GeneratedTangleApiOperations from .logger import Logger, _null_logger from .models import ( ComponentInfo, @@ -35,7 +35,7 @@ ) -class TangleApiClient(GeneratedOperationsMixin): +class TangleApiClient(GeneratedTangleApiOperations): """Single public API wrapper for Tangle backends. The constructor keeps the historical ``tangle-deploy`` shape while also diff --git a/tangle_cli/generated/operations.py b/tangle_cli/generated/operations.py index 35e30be..2cd7b4b 100644 --- a/tangle_cli/generated/operations.py +++ b/tangle_cli/generated/operations.py @@ -10,8 +10,8 @@ from .models import ComponentLibraryResponse, ComponentResponse, GetArtifactInfoResponse, GetArtifactSignedUrlResponse, GetContainerExecutionLogResponse, GetContainerExecutionStateResponse, GetExecutionArtifactsResponse, GetExecutionInfoResponse, GetGraphExecutionStateResponse, GetUserResponse, ListComponentLibrariesResponse, ListPipelineJobsResponse, ListPublishedComponentsResponse, ListSecretsResponse, PipelineRunResponse, PublishedComponentResponse, SecretInfoResponse, UserComponentLibraryPinsResponse, UserSettingsResponse -class GeneratedOperationsMixin: - """Mixin containing one checked-in method per OpenAPI operation.""" +class GeneratedTangleApiOperations: + """Generated checked-in methods for Tangle API operations.""" def admin_execution_node_status(self, id: Any, status: Any) -> None: return self._request_json( @@ -373,4 +373,4 @@ def users_delete_me_settings(self, setting_names: Any) -> None: response_model=None, ) -__all__ = ['GeneratedOperationsMixin'] +__all__ = ['GeneratedTangleApiOperations'] diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py index b5cddd7..c7fc6bb 100644 --- a/tangle_cli/openapi/codegen.py +++ b/tangle_cli/openapi/codegen.py @@ -28,6 +28,7 @@ _REPO_ROOT = Path(__file__).resolve().parents[2] _GENERATED_DIR = _REPO_ROOT / "tangle_cli" / "generated" DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle" +DEFAULT_OPERATIONS_CLASS_NAME = "GeneratedTangleApiOperations" def _safe_identifier(name: str) -> str: @@ -234,6 +235,12 @@ 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: + 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) -> tuple[str, list[str], list[str], list[str], bool]: required: list[Any] = [] optional: list[Any] = [] @@ -272,7 +279,11 @@ def _dict_literal(names: list[str]) -> str: return "{" + ", ".join(f"{name!r}: {name}" for name in names) + "}" -def generate_operations(schema: dict[str, Any]) -> str: +def generate_operations( + schema: dict[str, Any], + operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, +) -> str: + operations_class_name = _validate_class_name(operations_class_name) operations = parsed_operations(schema) response_models = sorted({name for op in operations if (name := _response_model_name(op.operation))}) imports = ", ".join(response_models) @@ -289,8 +300,8 @@ def generate_operations(schema: dict[str, Any]) -> str: lines.extend([ "", - "class GeneratedOperationsMixin:", - " \"\"\"Mixin containing one checked-in method per OpenAPI operation.\"\"\"", + f"class {operations_class_name}:", + " \"\"\"Generated checked-in methods for Tangle API operations.\"\"\"", "", ]) @@ -330,7 +341,7 @@ def generate_operations(schema: dict[str, Any]) -> str: "", ]) - lines.append(f"__all__ = {[ 'GeneratedOperationsMixin' ]!r}") + lines.append(f"__all__ = {[operations_class_name]!r}") lines.append("") return "\n".join(lines) @@ -431,6 +442,8 @@ def write_openapi_schema(schema: dict[str, Any], destination: str | Path = DEFAU def generate( openapi_path: str | Path = DEFAULT_OPENAPI_PATH, generated_dir: str | Path = _GENERATED_DIR, + *, + operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, ) -> tuple[dict[str, Any], list[Path]]: schema = load_openapi_schema(openapi_path) output_dir = Path(generated_dir) @@ -445,7 +458,10 @@ def generate( encoding="utf-8", ) generated_files[1].write_text(generate_models(schema), encoding="utf-8") - generated_files[2].write_text(generate_operations(schema), encoding="utf-8") + generated_files[2].write_text( + generate_operations(schema, operations_class_name=operations_class_name), + encoding="utf-8", + ) return schema, generated_files @@ -481,6 +497,14 @@ def main(argv: list[str] | None = None) -> None: default=str(_GENERATED_DIR), help="Generated support module directory (default: tangle_cli/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( "--openapi-url", default=None, @@ -505,6 +529,10 @@ def main(argv: list[str] | None = None) -> None: help="Regenerate support modules from the existing local openapi.json snapshot.", ) args = parser.parse_args(argv) + try: + _validate_class_name(args.operations_class_name) + 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") @@ -533,7 +561,11 @@ def main(argv: list[str] | None = None) -> None: source = f"backend: {_display_path(backend_path)}" wrote_openapi = True - schema, generated_files = generate(args.openapi, args.out) + schema, generated_files = generate( + args.openapi, + args.out, + operations_class_name=args.operations_class_name, + ) _print_summary( source=source, openapi_path=args.openapi, diff --git a/tests/test_codegen.py b/tests/test_codegen.py index db33f78..fed1729 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -82,8 +82,15 @@ def fake_update_openapi_from_backend(**kwargs): openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") return openapi_path - def fake_generate(openapi_path, generated_dir): - calls.append(("generate", {"openapi_path": openapi_path, "generated_dir": generated_dir})) + 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) @@ -100,6 +107,7 @@ def fake_generate(openapi_path, generated_dir): assert calls[0][0] == "update" assert calls[0][1]["backend_path"] == backend assert calls[1][0] == "generate" + assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" output = capsys.readouterr().out assert f"Loaded OpenAPI from backend: {backend}" in output assert f"Wrote {tmp_path / 'openapi.json'}" in output @@ -128,8 +136,15 @@ def test_codegen_main_from_snapshot_is_explicit(monkeypatch, tmp_path, capsys) - 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): - calls.append(("generate", {"openapi_path": openapi_path, "generated_dir": generated_dir})) + 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) @@ -145,12 +160,43 @@ def fake_generate(openapi_path, generated_dir): ]) assert calls[0][0] == "generate" + assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiOperations" 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_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" + + def test_codegen_main_fetches_from_openapi_url_before_generating( monkeypatch, tmp_path, capsys ) -> None: @@ -162,8 +208,15 @@ def fake_update_openapi_from_url(openapi_url, **kwargs): openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") return openapi_path - def fake_generate(openapi_path, generated_dir): - calls.append(("generate", {"openapi_path": openapi_path, "generated_dir": generated_dir})) + 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) @@ -186,6 +239,7 @@ def fake_generate(openapi_path, generated_dir): }, ) assert calls[1][0] == "generate" + assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" output = capsys.readouterr().out assert "Loaded OpenAPI from URL: https://example.com/openapi.json" in output assert "Generated 1 operations from 1 paths" in output @@ -222,11 +276,47 @@ def test_generate_writes_support_modules_to_custom_out(tmp_path) -> None: assert (out / "__init__.py").exists() assert (out / "models.py").exists() operations = (out / "operations.py").read_text(encoding="utf-8") - assert "class GeneratedOperationsMixin" in operations + assert "class GeneratedTangleApiOperations" in operations assert "def published_components_list" in operations assert "name_substring" in operations +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 "__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", @@ -318,6 +408,8 @@ def test_generate_operations_uses_concrete_return_annotations() -> None: }, }) + assert "class GeneratedTangleApiOperations" 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 From 7a16cdcb896d1c1f1b682060c25a224ec08524fa Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 13:15:10 -0700 Subject: [PATCH 011/111] fix: declare generated operations host request method --- tangle_cli/generated/operations.py | 15 ++++++++++++++- tangle_cli/openapi/codegen.py | 15 ++++++++++++++- tests/test_codegen.py | 7 +++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tangle_cli/generated/operations.py b/tangle_cli/generated/operations.py index 2cd7b4b..5b9ba85 100644 --- a/tangle_cli/generated/operations.py +++ b/tangle_cli/generated/operations.py @@ -5,7 +5,8 @@ from __future__ import annotations -from typing import Any +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 @@ -13,6 +14,18 @@ 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', diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py index c7fc6bb..562985c 100644 --- a/tangle_cli/openapi/codegen.py +++ b/tangle_cli/openapi/codegen.py @@ -292,7 +292,8 @@ def generate_operations( "", "from __future__ import annotations", "", - "from typing import Any", + "from collections.abc import Mapping", + "from typing import TYPE_CHECKING, Any", "", ] if imports: @@ -303,6 +304,18 @@ def generate_operations( 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() diff --git a/tests/test_codegen.py b/tests/test_codegen.py index fed1729..aef61f0 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -301,6 +301,8 @@ def test_generate_supports_custom_operations_class_name(tmp_path) -> None: 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 @@ -408,7 +410,12 @@ def test_generate_operations_uses_concrete_return_annotations() -> None: }, }) + 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 From 4649d3fedac49d50b81d5ad34ee5d33ed76f5ca2 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 13:52:06 -0700 Subject: [PATCH 012/111] feat: support generated model extensions --- README.md | 24 +++++-- tangle_cli/generated/models.py | 4 +- tangle_cli/generated_model_extensions.py | 38 +++++++++++ tangle_cli/openapi/codegen.py | 83 ++++++++++++++++++++++-- tests/test_codegen.py | 77 ++++++++++++++++++++++ tests/test_static_client.py | 18 +++++ 6 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 tangle_cli/generated_model_extensions.py diff --git a/README.md b/README.md index 69b71f1..9934e07 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,8 @@ backend submodule, run: ```bash git submodule update --init --recursive uv sync --group codegen -uv run --group codegen python -m tangle_cli.openapi.codegen +uv run --group codegen python -m tangle_cli.openapi.codegen \ + --model-extension-module tangle_cli.generated_model_extensions uv run pytest ``` @@ -204,7 +205,19 @@ with `git submodule update --init --recursive`. `tangle_cli/generated`, which is the package support module used by the public `tangle_cli/client.py` wrapper. `--operations-class-name` controls the generated operations class name in `/operations.py`; it defaults to -`GeneratedTangleApiOperations`. Codegen writes exactly these support files: +`GeneratedTangleApiOperations`. `--model-extension-module` points codegen at an +importable module with a `MODEL_EXTENSIONS` mapping from generated model class +names to extension class names. Matching generated models inherit those +extensions before `TangleGeneratedModel`, e.g.: + +```python +MODEL_EXTENSIONS = { + "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", +} +``` + +The extension classes must be importable from that module and should not import +generated model classes. Codegen writes exactly these support files: ```text /__init__.py @@ -219,7 +232,9 @@ To regenerate from the already checked-in snapshot instead of the backend, pass `--from-snapshot` explicitly: ```bash -uv run python -m tangle_cli.openapi.codegen --from-snapshot +uv run python -m tangle_cli.openapi.codegen \ + --from-snapshot \ + --model-extension-module tangle_cli.generated_model_extensions ``` If you already have a remote OpenAPI JSON document, fetch that directly instead: @@ -240,7 +255,8 @@ Downstream tools can point `--out` at their own generated support package, e.g.: uv run python -m tangle_cli.openapi.codegen \ --openapi-url https://oasis.shopify.io/openapi.json \ --out src/tangle_deploy/generated_api \ - --operations-class-name GeneratedTangleApiExtensions + --operations-class-name GeneratedTangleApiExtensions \ + --model-extension-module tangle_deploy.tangle_api_model_extensions ``` At the time of writing the official repository does not commit that raw diff --git a/tangle_cli/generated/models.py b/tangle_cli/generated/models.py index 98bc2ba..213f49d 100644 --- a/tangle_cli/generated/models.py +++ b/tangle_cli/generated/models.py @@ -14,6 +14,8 @@ except ImportError: # pragma: no cover - pydantic v1 fallback ConfigDict = None # type: ignore[assignment] +from tangle_cli.generated_model_extensions import GetGraphExecutionStateResponseExtensions + class TangleGeneratedModel(BaseModel): """Base for generated response models with dict-like conveniences.""" @@ -191,7 +193,7 @@ class GetExecutionInfoResponse(TangleGeneratedModel): pipeline_run_id: Any = None task_spec: Any = None -class GetGraphExecutionStateResponse(TangleGeneratedModel): +class GetGraphExecutionStateResponse(GetGraphExecutionStateResponseExtensions, TangleGeneratedModel): child_execution_status_stats: Any = None child_execution_status_summary: Any = None diff --git a/tangle_cli/generated_model_extensions.py b/tangle_cli/generated_model_extensions.py new file mode 100644 index 0000000..4af3325 --- /dev/null +++ b/tangle_cli/generated_model_extensions.py @@ -0,0 +1,38 @@ +"""Handwritten extensions mixed into generated Tangle API models.""" + +from __future__ import annotations + +from typing import cast + + +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 = { + "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", +} diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py index 562985c..f4d73da 100644 --- a/tangle_cli/openapi/codegen.py +++ b/tangle_cli/openapi/codegen.py @@ -160,8 +160,49 @@ def _schema_return_annotation(schema: dict[str, Any]) -> str: return "Any" -def generate_models(schema: dict[str, Any]) -> str: + +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 | None) -> dict[str, str]: + if not module_name: + return {} + 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 generate_models( + schema: dict[str, Any], + model_extension_module: str | None = None, +) -> str: schemas = schema.get("components", {}).get("schemas", {}) or {} + extension_mapping = _model_extension_mapping(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"""', "", @@ -176,6 +217,24 @@ def generate_models(schema: dict[str, Any]) -> str: "except ImportError: # pragma: no cover - pydantic v1 fallback", " ConfigDict = None # type: ignore[assignment]", "", + ] + + 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_mapping[class_name] + for class_name in sorted(generated_class_names) + if class_name in extension_mapping + } + if model_extension_module and used_extensions: + imports = ", ".join(sorted(set(used_extensions.values()))) + lines.extend([f"from {model_extension_module} import {imports}", ""]) + + lines.extend([ "", "class TangleGeneratedModel(BaseModel):", " \"\"\"Base for generated response models with dict-like conveniences.\"\"\"", @@ -204,7 +263,7 @@ def generate_models(schema: dict[str, Any]) -> str: " return cls.model_validate(data)", " return cls.parse_obj(data)", "", - ] + ]) exports = ["TangleGeneratedModel"] for schema_name, schema_def in sorted(schemas.items(), key=lambda item: _class_name(item[0])): @@ -214,7 +273,9 @@ def generate_models(schema: dict[str, Any]) -> str: lines.extend([f"{class_name} = Any", ""]) continue properties = schema_def.get("properties") or {} - lines.extend([f"class {class_name}(TangleGeneratedModel):"]) + bases = [used_extensions[class_name]] if class_name in used_extensions else [] + bases.append("TangleGeneratedModel") + lines.extend([f"class {class_name}({', '.join(bases)}):"]) if not properties: lines.append(" pass") else: @@ -457,6 +518,7 @@ def generate( generated_dir: str | Path = _GENERATED_DIR, *, operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, + model_extension_module: str | None = None, ) -> tuple[dict[str, Any], list[Path]]: schema = load_openapi_schema(openapi_path) output_dir = Path(generated_dir) @@ -470,7 +532,10 @@ def generate( '"""Generated OpenAPI support modules."""\n', encoding="utf-8", ) - generated_files[1].write_text(generate_models(schema), encoding="utf-8") + generated_files[1].write_text( + generate_models(schema, model_extension_module=model_extension_module), + encoding="utf-8", + ) generated_files[2].write_text( generate_operations(schema, operations_class_name=operations_class_name), encoding="utf-8", @@ -518,6 +583,14 @@ def main(argv: list[str] | None = None) -> None: f"(default: {DEFAULT_OPERATIONS_CLASS_NAME})." ), ) + parser.add_argument( + "--model-extension-module", + default=None, + help=( + "Optional importable module containing a MODEL_EXTENSIONS mapping " + "from generated model class names to extension class names." + ), + ) parser.add_argument( "--openapi-url", default=None, @@ -544,6 +617,7 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) try: _validate_class_name(args.operations_class_name) + _model_extension_mapping(args.model_extension_module) 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)) @@ -578,6 +652,7 @@ def main(argv: list[str] | None = None) -> None: args.openapi, args.out, operations_class_name=args.operations_class_name, + model_extension_module=args.model_extension_module, ) _print_summary( source=source, diff --git a/tests/test_codegen.py b/tests/test_codegen.py index aef61f0..5a6395b 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -281,6 +281,83 @@ def test_generate_writes_support_modules_to_custom_out(tmp_path) -> None: assert "name_substring" in operations +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 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 FooResponse(FooResponseExtensions, TangleGeneratedModel):" in models + assert "class OtherResponse(TangleGeneratedModel):" in models + + +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" diff --git a/tests/test_static_client.py b/tests/test_static_client.py index b85f36f..f8ac4fa 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -7,6 +7,7 @@ from tangle_cli import TangleApiClient from tangle_cli.generated.models import ( + GetGraphExecutionStateResponse, ListPublishedComponentsResponse, PipelineRunResponse, PublishedComponentResponse, @@ -38,6 +39,23 @@ def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: 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"] + + + def test_public_static_client_import_and_generated_operation() -> None: session = FakeSession([ response({"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}) From 08d39b53f3c159f9515ae630710facfe80e759dc Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 14:00:06 -0700 Subject: [PATCH 013/111] fix: default generated model extensions --- README.md | 13 +++--- tangle_cli/openapi/codegen.py | 21 +++++++--- tests/test_codegen.py | 78 +++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9934e07..4e7d9cf 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,7 @@ backend submodule, run: ```bash git submodule update --init --recursive uv sync --group codegen -uv run --group codegen python -m tangle_cli.openapi.codegen \ - --model-extension-module tangle_cli.generated_model_extensions +uv run --group codegen python -m tangle_cli.openapi.codegen uv run pytest ``` @@ -207,8 +206,10 @@ with `git submodule update --init --recursive`. operations class name in `/operations.py`; it defaults to `GeneratedTangleApiOperations`. `--model-extension-module` points codegen at an importable module with a `MODEL_EXTENSIONS` mapping from generated model class -names to extension class names. Matching generated models inherit those -extensions before `TangleGeneratedModel`, e.g.: +names to extension class names. It defaults to +`tangle_cli.generated_model_extensions`; pass an empty string (`--model-extension-module ""`) +to disable extensions. Matching generated models inherit those extensions before +`TangleGeneratedModel`, e.g.: ```python MODEL_EXTENSIONS = { @@ -232,9 +233,7 @@ To regenerate from the already checked-in snapshot instead of the backend, pass `--from-snapshot` explicitly: ```bash -uv run python -m tangle_cli.openapi.codegen \ - --from-snapshot \ - --model-extension-module tangle_cli.generated_model_extensions +uv run python -m tangle_cli.openapi.codegen --from-snapshot ``` If you already have a remote OpenAPI JSON document, fetch that directly instead: diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py index f4d73da..c869a43 100644 --- a/tangle_cli/openapi/codegen.py +++ b/tangle_cli/openapi/codegen.py @@ -29,6 +29,7 @@ _GENERATED_DIR = _REPO_ROOT / "tangle_cli" / "generated" DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle" DEFAULT_OPERATIONS_CLASS_NAME = "GeneratedTangleApiOperations" +DEFAULT_MODEL_EXTENSION_MODULE = "tangle_cli.generated_model_extensions" def _safe_identifier(name: str) -> str: @@ -161,6 +162,12 @@ def _schema_return_annotation(schema: dict[str, Any]) -> str: +def _normalize_model_extension_module(module_name: str | None) -> str | None: + if module_name == "": + return None + return module_name + + 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): @@ -169,6 +176,7 @@ def _validate_module_name(module_name: str) -> str: def _model_extension_mapping(module_name: str | None) -> dict[str, str]: + module_name = _normalize_model_extension_module(module_name) if not module_name: return {} module_name = _validate_module_name(module_name) @@ -199,9 +207,10 @@ def _model_extension_mapping(module_name: str | None) -> dict[str, str]: def generate_models( schema: dict[str, Any], - model_extension_module: str | None = None, + model_extension_module: str | None = DEFAULT_MODEL_EXTENSION_MODULE, ) -> str: schemas = schema.get("components", {}).get("schemas", {}) or {} + model_extension_module = _normalize_model_extension_module(model_extension_module) extension_mapping = _model_extension_mapping(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"""', @@ -518,7 +527,7 @@ def generate( generated_dir: str | Path = _GENERATED_DIR, *, operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, - model_extension_module: str | None = None, + model_extension_module: str | None = DEFAULT_MODEL_EXTENSION_MODULE, ) -> tuple[dict[str, Any], list[Path]]: schema = load_openapi_schema(openapi_path) output_dir = Path(generated_dir) @@ -585,10 +594,12 @@ def main(argv: list[str] | None = None) -> None: ) parser.add_argument( "--model-extension-module", - default=None, + default=DEFAULT_MODEL_EXTENSION_MODULE, help=( - "Optional importable module containing a MODEL_EXTENSIONS mapping " - "from generated model class names to extension class names." + "Importable module containing a MODEL_EXTENSIONS mapping from " + "generated model class names to extension class names; pass an " + "empty string to disable. " + f"(default: {DEFAULT_MODEL_EXTENSION_MODULE})." ), ) parser.add_argument( diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 5a6395b..6b48163 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -108,6 +108,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][1]["backend_path"] == backend assert calls[1][0] == "generate" assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" + assert calls[1][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE output = capsys.readouterr().out assert f"Loaded OpenAPI from backend: {backend}" in output assert f"Wrote {tmp_path / 'openapi.json'}" in output @@ -161,6 +162,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiOperations" + assert calls[0][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE 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 @@ -195,6 +197,35 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiExtensions" + assert calls[0][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE + + +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"] == "" def test_codegen_main_fetches_from_openapi_url_before_generating( @@ -240,6 +271,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): ) assert calls[1][0] == "generate" assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" + assert calls[1][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE output = capsys.readouterr().out assert "Loaded OpenAPI from URL: https://example.com/openapi.json" in output assert "Generated 1 operations from 1 paths" in output @@ -281,6 +313,52 @@ def test_generate_writes_support_modules_to_custom_out(tmp_path) -> None: assert "name_substring" 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_model_extensions import " + "GetGraphExecutionStateResponseExtensions" + ) in models + assert ( + "class GetGraphExecutionStateResponse(" + "GetGraphExecutionStateResponseExtensions, TangleGeneratedModel):" + ) 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 GetGraphExecutionStateResponse(TangleGeneratedModel):" in models + + def test_generate_supports_model_extension_module(monkeypatch, tmp_path) -> None: extension_dir = tmp_path / "extensions" extension_dir.mkdir() From 2fdafed06b95206944481452ab9ee81707f85ea7 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 15:04:43 -0700 Subject: [PATCH 014/111] feat: align existing component search helper --- README.md | 11 +++- tangle_cli/client.py | 46 +++++++------ tests/test_static_client.py | 125 ++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4e7d9cf..774fa1a 100644 --- a/README.md +++ b/README.md @@ -176,12 +176,19 @@ The stable public wrapper for downstream Python tools is: from tangle_cli.client import TangleApiClient client = TangleApiClient("http://localhost:8000") -run = client.get_pipeline_run("run-id") +run = client.pipeline_runs_get("run-id") +existing = client.find_existing_components( + ["component-name"], + published_by_substring="alice@example.com", +) ``` `TangleApiClient` uses checked-in endpoint methods generated offline from `tangle_cli/openapi/openapi.json`, so normal imports do not fetch or parse the -OpenAPI schema. +OpenAPI schema. Handwritten semantic helpers such as +`find_existing_components(...)` return domain models; that helper accepts +component specs, mapping references, or plain names plus optional names/digests +and publisher filters, and returns a de-duplicated `list[ComponentInfo]`. To refresh the checked-in generated methods/models from the official Tangle backend submodule, run: diff --git a/tangle_cli/client.py b/tangle_cli/client.py index d5cc777..a8efefa 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -296,48 +296,65 @@ def list_published_component_infos( def find_existing_components( self, - components: Iterable[ComponentSpec | Mapping[str, Any]] | None = None, + components: Iterable[ComponentSpec | Mapping[str, Any] | str] | None = None, *, names: Iterable[str] | None = None, digests: Iterable[str] | None = None, include_deprecated: bool = False, - ) -> dict[str, ComponentInfo]: - """Find published components matching provided component specs/names/digests. + 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. - The result is keyed by every useful identifier we can infer (digest and - name), which keeps the helper compatible with callers that check either. + ``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, ComponentSpec): + 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, dict): + 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, ): - _index_component_info(found, info) + 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 == name: - _index_component_info(found, info) - return found - + add(info) + return list(found.values()) def get_run_details( self, @@ -407,11 +424,4 @@ def _to_plain(value: Any) -> Any: return value -def _index_component_info(index: dict[str, ComponentInfo], info: ComponentInfo) -> None: - if info.digest: - index[info.digest] = info - if info.name: - index[info.name] = info - - __all__ = ["TangleApiClient"] diff --git a/tests/test_static_client.py b/tests/test_static_client.py index f8ac4fa..50a6b09 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -6,6 +6,7 @@ import requests from tangle_cli import TangleApiClient +from tangle_cli.logger import CaptureLogger from tangle_cli.generated.models import ( GetGraphExecutionStateResponse, ListPublishedComponentsResponse, @@ -13,6 +14,7 @@ PublishedComponentResponse, SecretInfoResponse, ) +from tangle_cli.models import ComponentSpec def response(payload: Any = None, status_code: int = 200) -> requests.Response: @@ -218,6 +220,129 @@ def published_components_list( 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", From 2da9609451f1513031ce870d85da4c99b340eda9 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 15:38:31 -0700 Subject: [PATCH 015/111] fix: use static operations in component inspector --- tangle_cli/component_inspector.py | 58 ++++++++++++++++------ tests/test_component_inspector.py | 80 ++++++++++++++----------------- 2 files changed, 79 insertions(+), 59 deletions(-) diff --git a/tangle_cli/component_inspector.py b/tangle_cli/component_inspector.py index 458ab0d..f01f51c 100644 --- a/tangle_cli/component_inspector.py +++ b/tangle_cli/component_inspector.py @@ -19,19 +19,17 @@ class ComponentApiClient(Protocol): - """Small subset of :class:`tangle_cli.dynamic_discovery_client.TangleDynamicDiscoveryClient` used here.""" + """Small subset of Tangle API clients used by component inspection.""" base_url: str - def call(self, operation_name: str, **params: Any) -> Any: ... - def _request_path(client: ComponentApiClient, path: str) -> httpx.Response: - """Fetch an API-origin path using a dynamic-discovery client's auth settings. + """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 dynamic client's base URL and auth/header precedence + helper preserves the client's base URL and auth/header precedence without depending on the removed legacy hand-written client module. """ @@ -60,13 +58,31 @@ def _request_path(client: ComponentApiClient, path: str) -> httpx.Response: return response +def _to_plain(value: Any) -> Any: + 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) + return value + + +def _is_not_found_error(exc: Exception) -> bool: + response = getattr(exc, "response", None) + return getattr(response, "status_code", None) == 404 + + def _component_response(client: ComponentApiClient, digest: str) -> dict[str, Any] | None: try: - data = client.call("components.get", digest=digest) - except httpx.HTTPStatusError as exc: - if exc.response.status_code == 404: + components_get = getattr(client, "components_get", None) + if callable(components_get): + data = components_get(digest) + else: + data = client.components.get(digest=digest) # type: ignore[attr-defined] + except Exception as exc: + if _is_not_found_error(exc): return None raise + data = _to_plain(data) return data if isinstance(data, dict) else None @@ -78,13 +94,22 @@ def _published_components( published_by_substring: str | None = None, digest: str | None = None, ) -> list[dict[str, Any]]: - result = client.call( - "published-components.list", - include_deprecated=include_deprecated, - name_substring=name_substring, - published_by_substring=published_by_substring, - digest=digest, - ) + published_components_list = getattr(client, "published_components_list", None) + if callable(published_components_list): + result = published_components_list( + include_deprecated=include_deprecated, + name_substring=name_substring, + published_by_substring=published_by_substring, + digest=digest, + ) + else: + result = client.published_components.list( # type: ignore[attr-defined] + include_deprecated=include_deprecated, + name_substring=name_substring, + published_by_substring=published_by_substring, + digest=digest, + ) + result = _to_plain(result) if isinstance(result, dict) and isinstance(result.get("published_components"), list): return result["published_components"] if isinstance(result, list): @@ -350,6 +375,9 @@ def _enrich_with_spec( def _get_component_spec(client: ComponentApiClient, digest: str) -> ComponentSpec | None: + get_component_spec = getattr(client, "get_component_spec", None) + if callable(get_component_spec): + return get_component_spec(digest) data = _component_response(client, digest) return ComponentSpec.from_dict(data) if data is not None else None diff --git a/tests/test_component_inspector.py b/tests/test_component_inspector.py index 7b25975..63b2d01 100644 --- a/tests/test_component_inspector.py +++ b/tests/test_component_inspector.py @@ -47,36 +47,35 @@ def __init__(self): }, } - def call(self, operation_name: str, **params: Any) -> Any: - if operation_name == "components.get": - return self.component if params.get("digest") == "abc123" else None - if operation_name == "published-components.list": - if params.get("digest") == "abc123" or params.get("name_substring") == "demo": - return { - "published_components": [ - { - "name": "demo", - "digest": "abc123", - "version": "1.2.3", - "published_by": "user@example.com", - "description": "Demo component", - } - ] - } - if params.get("digest") == "old": - return { - "published_components": [ - { - "name": "demo", - "digest": "old", - "version": "1.0.0", - "deprecated": True, - "superseded_by": "abc123", - } - ] - } - return {"published_components": []} - raise AssertionError(f"unexpected operation: {operation_name}") + def components_get(self, digest: str) -> Any: + return self.component if digest == "abc123" else None + + def published_components_list(self, **params: Any) -> Any: + if params.get("digest") == "abc123" or params.get("name_substring") == "demo": + return { + "published_components": [ + { + "name": "demo", + "digest": "abc123", + "version": "1.2.3", + "published_by": "user@example.com", + "description": "Demo component", + } + ] + } + if params.get("digest") == "old": + return { + "published_components": [ + { + "name": "demo", + "digest": "old", + "version": "1.0.0", + "deprecated": True, + "superseded_by": "abc123", + } + ] + } + return {"published_components": []} class TestTransparencyCheck: @@ -115,9 +114,6 @@ class LibraryClient: def __init__(self): self.paths: list[str] = [] - def call(self, operation_name: str, **params: Any) -> Any: - raise AssertionError(f"unexpected operation: {operation_name}") - def request_path(self, path: str): self.paths.append(path) if path == "/component_library.yaml": @@ -146,9 +142,6 @@ class LibraryClient: def __init__(self): self.paths: list[str] = [] - def call(self, operation_name: str, **params: Any) -> Any: - raise AssertionError(f"unexpected operation: {operation_name}") - def request_path(self, path: str): self.paths.append(path) if path == "/component_library.yaml": @@ -177,10 +170,11 @@ def __init__(self, component_name: str | None): self.component_name = component_name self.paths: list[str] = [] - def call(self, operation_name: str, **params: Any) -> Any: - if operation_name in {"components.get", "published-components.list"}: - return {"published_components": []} - raise AssertionError(f"unexpected operation: {operation_name}") + def components_get(self, digest: str) -> Any: + return None + + def published_components_list(self, **params: Any) -> Any: + return {"published_components": []} def request_path(self, path: str): self.paths.append(path) @@ -252,10 +246,8 @@ def test_search_components_returns_summary_rows(self): def test_search_components_handles_null_description(self): class NullDescriptionClient(FakeClient): - def call(self, operation_name: str, **params: Any) -> Any: - if operation_name == "published-components.list": - return {"published_components": [{"name": "demo", "digest": "abc123", "description": None}]} - return super().call(operation_name, **params) + def published_components_list(self, **params: Any) -> Any: + return {"published_components": [{"name": "demo", "digest": "abc123", "description": None}]} result = search_components(NullDescriptionClient(), name="demo") From aa8d45542b2adc50372d9b7b654090d63cd0df29 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 15:46:58 -0700 Subject: [PATCH 016/111] refactor: make component inspector static-client native --- tangle_cli/component_inspector.py | 156 ++++++++----------------- tangle_cli/published_components_cli.py | 8 +- tests/test_api_cli.py | 17 +++ tests/test_component_inspector.py | 64 +++++----- 4 files changed, 98 insertions(+), 147 deletions(-) diff --git a/tangle_cli/component_inspector.py b/tangle_cli/component_inspector.py index f01f51c..bc1e8b6 100644 --- a/tangle_cli/component_inspector.py +++ b/tangle_cli/component_inspector.py @@ -3,121 +3,60 @@ from __future__ import annotations from pathlib import PurePosixPath -from typing import Any, Protocol +from typing import Any from urllib.parse import urljoin, urlparse from weakref import WeakKeyDictionary -import httpx import yaml -from tangle_cli.api_transport import _request_headers +from tangle_cli.client import TangleApiClient from tangle_cli.models import ComponentInfo, ComponentSpec # ============================================================================ -# Client protocol helpers +# Client helpers # ============================================================================ -class ComponentApiClient(Protocol): - """Small subset of Tangle API clients used by component inspection.""" - - base_url: str - - -def _request_path(client: ComponentApiClient, path: str) -> httpx.Response: +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 client's base URL and auth/header precedence - without depending on the removed legacy hand-written client module. + 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) - if hasattr(response, "raise_for_status"): - response.raise_for_status() - return response - - base_url = client.base_url.rstrip("/") + "/" - url = urljoin(base_url, path.lstrip("/")) - headers = _request_headers( - getattr(client, "token", None), - getattr(client, "header", None), - getattr(client, "auth_header", None), - getattr(client, "headers", None), - ) - response = httpx.request( - "GET", - url, - headers=headers, - timeout=getattr(client, "timeout", 30.0), - ) + else: + response = client._make_request("GET", path) response.raise_for_status() return response -def _to_plain(value: Any) -> Any: - 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) - return value - - def _is_not_found_error(exc: Exception) -> bool: response = getattr(exc, "response", None) return getattr(response, "status_code", None) == 404 -def _component_response(client: ComponentApiClient, digest: str) -> dict[str, Any] | None: - try: - components_get = getattr(client, "components_get", None) - if callable(components_get): - data = components_get(digest) - else: - data = client.components.get(digest=digest) # type: ignore[attr-defined] - except Exception as exc: - if _is_not_found_error(exc): - return None - raise - data = _to_plain(data) - return data if isinstance(data, dict) else None - - def _published_components( - client: ComponentApiClient, + client: TangleApiClient, *, include_deprecated: bool = False, name_substring: str | None = None, published_by_substring: str | None = None, digest: str | None = None, -) -> list[dict[str, Any]]: - published_components_list = getattr(client, "published_components_list", None) - if callable(published_components_list): - result = published_components_list( - include_deprecated=include_deprecated, - name_substring=name_substring, - published_by_substring=published_by_substring, - digest=digest, - ) - else: - result = client.published_components.list( # type: ignore[attr-defined] - include_deprecated=include_deprecated, - name_substring=name_substring, - published_by_substring=published_by_substring, - digest=digest, - ) - result = _to_plain(result) - if isinstance(result, dict) and isinstance(result.get("published_components"), list): - return result["published_components"] - if isinstance(result, list): - return result - return [] +) -> 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: ComponentApiClient, digest: str) -> str: +def _resolve_digest(client: TangleApiClient, digest: str) -> str: current = digest seen: set[str] = set() @@ -133,10 +72,10 @@ def _resolve_digest(client: ComponentApiClient, digest: str) -> str: break meta = published[0] - if not meta.get("deprecated", False): + if not meta.deprecated: break - superseded_by = meta.get("superseded_by") + superseded_by = meta.superseded_by if not superseded_by: break @@ -158,7 +97,7 @@ def _resolve_digest(client: ComponentApiClient, digest: str) -> str: _component_libraries_by_client: WeakKeyDictionary[Any, _LibraryState] = WeakKeyDictionary() -def _library_fetch_path(client: ComponentApiClient, url: str) -> str | None: +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 @@ -179,7 +118,7 @@ def _library_fetch_path(client: ComponentApiClient, url: str) -> str | None: return path -def _fetch_library_component(client: ComponentApiClient, url: str) -> ComponentSpec: +def _fetch_library_component(client: TangleApiClient, url: str) -> ComponentSpec: path = _library_fetch_path(client, url) if path is None: return ComponentSpec() @@ -191,7 +130,7 @@ def _fetch_library_component(client: ComponentApiClient, url: str) -> ComponentS return ComponentSpec() -def _parse_component_library(raw: dict[str, Any], client: ComponentApiClient) -> dict[str, Any]: +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 @@ -234,7 +173,7 @@ def _strip_entry(entry: dict[str, Any]) -> dict[str, Any]: return result -def _ensure_library_loaded(client: ComponentApiClient) -> _LibraryState: +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) @@ -259,7 +198,7 @@ def _ensure_library_loaded(client: ComponentApiClient) -> _LibraryState: return state -def get_standard_library(client: ComponentApiClient) -> dict[str, Any]: +def get_standard_library(client: TangleApiClient) -> dict[str, Any]: """Return the standard component library organised by folders. Each component entry has a stripped spec (no implementation blocks), an @@ -362,7 +301,7 @@ def _full_path(rel_path: str) -> str: def _enrich_with_spec( info: ComponentInfo, - client: ComponentApiClient, + client: TangleApiClient, ) -> None: """Fetch the full component data and attach it to *info*.""" @@ -374,24 +313,24 @@ def _enrich_with_spec( info.spec_error = str(e) -def _get_component_spec(client: ComponentApiClient, digest: str) -> ComponentSpec | None: - get_component_spec = getattr(client, "get_component_spec", None) - if callable(get_component_spec): - return get_component_spec(digest) - data = _component_response(client, digest) - return ComponentSpec.from_dict(data) if data is not None else None +def _get_component_spec(client: TangleApiClient, digest: str) -> ComponentSpec | None: + try: + return client.get_component_spec(digest) + except Exception as exc: + if _is_not_found_error(exc): + return None + raise def inspect_by_digest( - client: ComponentApiClient, + client: TangleApiClient, digest: str, full_spec: bool = False, follow_deprecated: bool = False, ) -> dict[str, Any]: """Inspect a single component by digest. - Fetches the full spec via the ``components.get`` OpenAPI operation and - publication metadata via ``published-components.list``. + Fetches the full spec and publication metadata via static client helpers. """ if follow_deprecated: @@ -419,7 +358,7 @@ def inspect_by_digest( pub_info = published[0] if published else None if pub_info: - info = ComponentInfo.from_dict(pub_info) + info = pub_info else: info = ComponentInfo(digest=digest) info.version = comp.version if comp else None @@ -443,7 +382,7 @@ def inspect_by_digest( def inspect_by_name( - client: ComponentApiClient, + client: TangleApiClient, name: str, include_all_versions: bool = False, include_deprecated: bool = False, @@ -458,9 +397,7 @@ def inspect_by_name( include_deprecated=include_deprecated, published_by_substring=published_by, ) - published = [ - c for c in published if c.get("name", "").lower() == name.lower() - ] + published = [c for c in published if str(c.name or "").lower() == name.lower()] if not published: _, library_cache = _ensure_library_loaded(client) @@ -481,10 +418,10 @@ def inspect_by_name( "message": f"No published component found with name: {name}", } - def _version_key(c: dict[str, Any]) -> tuple[int, ...]: + def _version_key(component: ComponentInfo) -> tuple[int, ...]: """Parse version string into numeric tuple for proper sorting.""" - v = c.get("version") or "0.0.1" + v = str(component.version or "0.0.1") try: return tuple(int(p) for p in v.split(".")) except ValueError: @@ -496,8 +433,7 @@ def _version_key(c: dict[str, Any]) -> tuple[int, ...]: published = published[:1] versions: list[dict[str, Any]] = [] - for pub in published: - info = ComponentInfo.from_dict(pub) + for info in published: _enrich_with_spec(info, client) entry = info.to_dict(strip_spec=not full_spec) if info.component_spec: @@ -518,7 +454,7 @@ def _version_key(c: dict[str, Any]) -> tuple[int, ...]: def search_components( - client: ComponentApiClient, + client: TangleApiClient, name: str | None = None, include_deprecated: bool = False, published_by: str | None = None, @@ -537,11 +473,11 @@ def search_components( results = [] for comp in components: results.append({ - "name": comp.get("name"), - "digest": comp.get("digest"), - "version": comp.get("version"), - "deprecated": comp.get("deprecated", False), - "description": (comp.get("description") or "")[:200], + "name": comp.name, + "digest": comp.digest, + "version": comp.version, + "deprecated": comp.deprecated, + "description": (comp.description or "")[:200], }) return { diff --git a/tangle_cli/published_components_cli.py b/tangle_cli/published_components_cli.py index 1c4bbce..c716ce5 100644 --- a/tangle_cli/published_components_cli.py +++ b/tangle_cli/published_components_cli.py @@ -7,8 +7,8 @@ from cyclopts import App, Parameter -from .dynamic_discovery_client import TangleDynamicDiscoveryClient from .api_transport import DEFAULT_TIMEOUT_SECONDS +from .client import TangleApiClient from .component_inspector import ( get_standard_library, inspect_by_digest, @@ -54,10 +54,10 @@ def _client_from_options( token: str | None = None, auth_header: str | None = None, header: list[str] | None = None, -) -> TangleDynamicDiscoveryClient: - """Create the dynamic-discovery client used by published-component commands.""" +) -> TangleApiClient: + """Create the static client used by published-component commands.""" - return TangleDynamicDiscoveryClient.from_cache_or_refresh( + return TangleApiClient( base_url=base_url, token=token, auth_header=auth_header, diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index b634729..e3dfe55 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -396,6 +396,23 @@ def fake_client_from_options(**kwargs): assert library_result == {"client_ok": True, "folders": []} +def test_sdk_published_components_client_from_options_uses_static_client(): + from tangle_cli.client import TangleApiClient + + client = published_components_cli._client_from_options( + base_url="https://api.test", + token="token", + auth_header="Bearer auth", + header=["X-Test: yes"], + ) + + 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"] + + def test_sdk_published_components_inspect_requires_name_or_digest(): app = cli.build_app() diff --git a/tests/test_component_inspector.py b/tests/test_component_inspector.py index 63b2d01..e03a9ae 100644 --- a/tests/test_component_inspector.py +++ b/tests/test_component_inspector.py @@ -1,4 +1,4 @@ -"""Tests for generic OpenAPI-backed component inspection helpers.""" +"""Tests for static-client-backed component inspection helpers.""" from __future__ import annotations @@ -15,7 +15,7 @@ search_components, transparency_check, ) -from tangle_cli.models import ComponentSpec +from tangle_cli.models import ComponentInfo, ComponentSpec @dataclass @@ -47,35 +47,33 @@ def __init__(self): }, } - def components_get(self, digest: str) -> Any: - return self.component if digest == "abc123" else None + def get_component_spec(self, digest: str) -> ComponentSpec | None: + if digest != "abc123": + return None + return ComponentSpec.from_dict(self.component) - def published_components_list(self, **params: Any) -> Any: + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: if params.get("digest") == "abc123" or params.get("name_substring") == "demo": - return { - "published_components": [ - { - "name": "demo", - "digest": "abc123", - "version": "1.2.3", - "published_by": "user@example.com", - "description": "Demo component", - } - ] - } + return [ + ComponentInfo( + name="demo", + digest="abc123", + version="1.2.3", + published_by="user@example.com", + description="Demo component", + ) + ] if params.get("digest") == "old": - return { - "published_components": [ - { - "name": "demo", - "digest": "old", - "version": "1.0.0", - "deprecated": True, - "superseded_by": "abc123", - } - ] - } - return {"published_components": []} + return [ + ComponentInfo( + name="demo", + digest="old", + version="1.0.0", + deprecated=True, + superseded_by="abc123", + ) + ] + return [] class TestTransparencyCheck: @@ -170,11 +168,11 @@ def __init__(self, component_name: str | None): self.component_name = component_name self.paths: list[str] = [] - def components_get(self, digest: str) -> Any: + def get_component_spec(self, digest: str) -> ComponentSpec | None: return None - def published_components_list(self, **params: Any) -> Any: - return {"published_components": []} + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + return [] def request_path(self, path: str): self.paths.append(path) @@ -246,8 +244,8 @@ def test_search_components_returns_summary_rows(self): def test_search_components_handles_null_description(self): class NullDescriptionClient(FakeClient): - def published_components_list(self, **params: Any) -> Any: - return {"published_components": [{"name": "demo", "digest": "abc123", "description": None}]} + def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: + return [ComponentInfo(name="demo", digest="abc123", description=None)] result = search_components(NullDescriptionClient(), name="demo") From 51087b24481ca29b310f66bb4a86902389034fee Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 17:05:24 -0700 Subject: [PATCH 017/111] refactor: extend generated component spec model --- README.md | 2 + tangle_cli/component_inspector.py | 9 + tangle_cli/generated/models.py | 4 +- tangle_cli/generated_model_extensions.py | 320 ++++++++++++++++++++++- tangle_cli/models.py | 262 +------------------ tangle_cli/openapi/codegen.py | 8 + tests/test_component_inspector.py | 24 ++ tests/test_models.py | 5 + 8 files changed, 374 insertions(+), 260 deletions(-) diff --git a/README.md b/README.md index 774fa1a..8c6f954 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ OpenAPI schema. Handwritten semantic helpers such as `find_existing_components(...)` return domain models; that helper accepts component specs, mapping references, or plain names plus optional names/digests and publisher filters, and returns a de-duplicated `list[ComponentInfo]`. +`ComponentSpec` is the generated OpenAPI model extended with YAML parsing and +legacy convenience helpers, and remains re-exported from `tangle_cli.models`. To refresh the checked-in generated methods/models from the official Tangle backend submodule, run: diff --git a/tangle_cli/component_inspector.py b/tangle_cli/component_inspector.py index bc1e8b6..05dc98f 100644 --- a/tangle_cli/component_inspector.py +++ b/tangle_cli/component_inspector.py @@ -299,6 +299,13 @@ def _full_path(rel_path: str) -> str: 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 _enrich_with_spec( info: ComponentInfo, client: TangleApiClient, @@ -309,6 +316,7 @@ def _enrich_with_spec( return try: info.component_spec = _get_component_spec(client, info.digest) + _backfill_version_from_spec(info) except Exception as e: info.spec_error = str(e) @@ -364,6 +372,7 @@ def inspect_by_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: diff --git a/tangle_cli/generated/models.py b/tangle_cli/generated/models.py index 213f49d..ed6499b 100644 --- a/tangle_cli/generated/models.py +++ b/tangle_cli/generated/models.py @@ -14,7 +14,7 @@ except ImportError: # pragma: no cover - pydantic v1 fallback ConfigDict = None # type: ignore[assignment] -from tangle_cli.generated_model_extensions import GetGraphExecutionStateResponseExtensions +from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetGraphExecutionStateResponseExtensions class TangleGeneratedModel(BaseModel): @@ -122,7 +122,7 @@ class ComponentResponse(TangleGeneratedModel): digest: Any = None text: Any = None -class ComponentSpec(TangleGeneratedModel): +class ComponentSpec(ComponentSpecExtensions, TangleGeneratedModel): description: Any = None implementation: Any = None inputs: Any = None diff --git a/tangle_cli/generated_model_extensions.py b/tangle_cli/generated_model_extensions.py index 4af3325..2fe298b 100644 --- a/tangle_cli/generated_model_extensions.py +++ b/tangle_cli/generated_model_extensions.py @@ -2,7 +2,324 @@ from __future__ import annotations -from typing import cast +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) + 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 GetGraphExecutionStateResponseExtensions: @@ -34,5 +351,6 @@ def failed_execution_ids(self) -> list[str]: MODEL_EXTENSIONS = { + "ComponentSpec": "ComponentSpecExtensions", "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", } diff --git a/tangle_cli/models.py b/tangle_cli/models.py index b7f8633..7b8549e 100644 --- a/tangle_cli/models.py +++ b/tangle_cli/models.py @@ -11,9 +11,8 @@ from dataclasses import asdict, dataclass, field from typing import Any -import yaml +from tangle_cli.generated.models import ComponentSpec -import tangle_cli.utils as utils # ---- Helpers --------------------------------------------------------------- @@ -399,260 +398,10 @@ def from_dict(cls, data: dict[str, Any]) -> SecretInfo: # ---- Components ------------------------------------------------------------ -@dataclass -class ComponentSpec: - """Component specification extracted from YAML content or API response.""" - - data: dict = field(default_factory=dict) # The full parsed YAML data structure - version: str | None = None - name: str = "" - description: str | None = None - digest: str = "" - text: str | None = None - annotations: dict[str, str] = field(default_factory=dict) - inputs: list[dict[str, Any]] = field(default_factory=list) - outputs: list[dict[str, Any]] = field(default_factory=list) - implementation: dict[str, Any] | None = None - - # ---- factories ---- - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> ComponentSpec: - """Create from a raw API response (``/api/components/{digest}``). - - Parses the ``text`` field into ``data`` when no ``spec`` key is present. - """ - spec = data.get("spec") - text = data.get("text") - if spec is None and text: - spec = yaml.safe_load(text) - spec = spec or {} - ann = spec.get("metadata", {}).get("annotations", {}) - return cls( - digest=data.get("digest", ""), - data=spec, - text=text, - name=spec.get("name", ""), - version=ann.get("version"), - description=spec.get("description"), - annotations=ann, - inputs=spec.get("inputs", []), - outputs=spec.get("outputs", []), - implementation=spec.get("implementation"), - ) - - @staticmethod - def from_yaml_file(yaml_path: str) -> ComponentSpec: - """Load and extract component specification from a YAML file. - - Raises: - FileNotFoundError: If the file doesn't exist - ValueError: If component name is missing or file is empty - """ - with open(yaml_path) as f: - yaml_content = f.read() - return ComponentSpec.from_yaml(yaml_content) - - @staticmethod - def from_yaml( - yaml_content: str, - annotations: dict[str, str] | None = None, - ) -> ComponentSpec: - """Extract component specification from YAML content. - - Args: - yaml_content: YAML string content - annotations: Optional annotations to add before extracting version - - Raises: - ValueError: If name is not found in YAML - """ - data = utils.parse_yaml_string(yaml_content) - - if not data: - raise ValueError("Unable to parse YAML content") - - # Apply custom annotations before extracting version - if annotations: - if 'metadata' not in data: - data['metadata'] = {} - if 'annotations' not in data['metadata']: - data['metadata']['annotations'] = {} - data['metadata']['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) - if not version: - version = None - - return ComponentSpec( - 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'), - ) - - @classmethod - def from_spec(cls, spec: dict[str, Any]) -> ComponentSpec: - """Create from an inline component spec dict (e.g. from an execution task_spec).""" - ann = spec.get("metadata", {}).get("annotations", {}) - return cls( - data=spec, - name=spec.get("name", ""), - description=spec.get("description"), - annotations=ann, - inputs=spec.get("inputs", []), - outputs=spec.get("outputs", []), - implementation=spec.get("implementation"), - ) - - def __bool__(self) -> bool: - return bool(self.data) - - # ---- properties ---- - - @property - def search_names(self) -> list[str]: - """Get names to use for searching (both original and with [Official] prefix).""" - return [self.name, add_official_prefix(self.name)] - - _STRIP_ANNOTATION_KEYS = {"python_original_code", "python_dependencies"} - - def strip_implementation(self, *, keep_graph: bool = False) -> None: - """Remove implementation details in-place. - - Args: - keep_graph: If True, preserve the graph structure but strip - ``text`` from nested ``componentRef`` objects. If False - (default), remove the implementation block entirely. - """ - self.text = None - if keep_graph: - if self.implementation: - _strip_text_from_graph(self.implementation) - else: - self.implementation = None - self.data.pop("implementation", None) - - @property - def stripped_spec(self) -> dict[str, Any] | None: - """Data with bulky annotations and implementation blocks removed. - - Returns a shallow copy — does not mutate ``self``. - """ - if not self.data: - return None - result = dict(self.data) - result.pop("implementation", None) - annotations = result.get("metadata", {}).get("annotations", {}) - if annotations: - result["metadata"] = dict(result["metadata"]) - result["metadata"]["annotations"] = { - k: v for k, v in annotations.items() if k not in self._STRIP_ANNOTATION_KEYS - } - return result - - # ---- serialisation ---- - - def to_yaml(self) -> str: - """Convert the component data back to YAML string.""" - return utils.dump_yaml(self.data) - - def save_to_file(self, file_path: str) -> None: - """Save the component spec to a YAML file.""" - import os - os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, 'w') as f: - f.write(self.to_yaml()) - - # ---- mutation helpers ---- - - def update_fields( - self, - git_remote_sha=None, - git_remote_branch=None, - git_remote_url=None, - image=None, - component_yaml_path=None, - ): - """Update component spec with publishing metadata (in-place). - - Returns self for method chaining. - """ - from datetime import datetime, timezone - - if 'metadata' not in self.data: - self.data['metadata'] = {} - if 'annotations' not in self.data['metadata']: - self.data['metadata']['annotations'] = {} - - self.data['metadata']['annotations']['published_at'] = datetime.now(timezone.utc).isoformat() - - annotations = self.data['metadata']['annotations'] - 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: - version = self.data.pop('version') - self.data['metadata']['annotations']['version'] = str(version) - if 'updated_at' in self.data: - updated_at = self.data.pop('updated_at') - self.data['metadata']['annotations']['updated_at'] = str(updated_at) - - if image: - if 'implementation' not in self.data: - self.data['implementation'] = {} - if 'container' not in self.data['implementation']: - self.data['implementation']['container'] = {} - self.data['implementation']['container']['image'] = image - - return self - - def fetch_from_url(self, url: str, timeout: int = 10) -> bool: - """Fetch spec from a URL, populating ``data`` and ``text``. - - Returns True if the fetch succeeded. - """ - 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", "") - return True - except Exception: - return False - - def ensure_digest(self) -> str | None: - """Compute and set ``digest`` if missing. Returns the digest. - - Resolution order: existing digest > hash of raw text > hash of data. - """ - if self.digest: - return self.digest - from tangle_cli.utils import compute_spec_digest, compute_text_digest - - if self.text: - self.digest = compute_text_digest(self.text) - elif self.data: - self.digest = compute_spec_digest(self.data) - return self.digest or None +# ``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 @@ -749,7 +498,6 @@ def _dataclass_getitem(self, key: str) -> Any: ArtifactInfo, UserInfo, SecretInfo, - ComponentSpec, ComponentInfo, PageChunk, ): diff --git a/tangle_cli/openapi/codegen.py b/tangle_cli/openapi/codegen.py index c869a43..57d5941 100644 --- a/tangle_cli/openapi/codegen.py +++ b/tangle_cli/openapi/codegen.py @@ -176,6 +176,8 @@ def _validate_module_name(module_name: str) -> str: def _model_extension_mapping(module_name: str | None) -> dict[str, str]: + """Load and validate a MODEL_EXTENSIONS mapping from an extension module.""" + module_name = _normalize_model_extension_module(module_name) if not module_name: return {} @@ -209,6 +211,8 @@ def generate_models( schema: dict[str, Any], model_extension_module: str | None = DEFAULT_MODEL_EXTENSION_MODULE, ) -> str: + """Generate Pydantic model classes and apply configured model extensions.""" + schemas = schema.get("components", {}).get("schemas", {}) or {} model_extension_module = _normalize_model_extension_module(model_extension_module) extension_mapping = _model_extension_mapping(model_extension_module) @@ -306,6 +310,8 @@ def _method_name(group_name: str, command_name: str) -> str: 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 @@ -353,6 +359,8 @@ def generate_operations( schema: dict[str, Any], operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, ) -> str: + """Generate the static operation mixin class for parsed OpenAPI operations.""" + operations_class_name = _validate_class_name(operations_class_name) operations = parsed_operations(schema) response_models = sorted({name for op in operations if (name := _response_model_name(op.operation))}) diff --git a/tests/test_component_inspector.py b/tests/test_component_inspector.py index e03a9ae..c7d493b 100644 --- a/tests/test_component_inspector.py +++ b/tests/test_component_inspector.py @@ -218,6 +218,30 @@ def test_inspect_by_digest_can_follow_deprecated_chain(self): 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 = inspect_by_digest(MissingPublishedVersionClient(), "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 = inspect_by_name(MissingPublishedVersionClient(), "demo") + + assert result["status"] == "success" + assert result["versions"][0]["version"] == "1.2.3" + def test_inspect_by_name_returns_matching_versions(self): result = inspect_by_name(FakeClient(), "demo") diff --git a/tests/test_models.py b/tests/test_models.py index 68de9a2..ffd6631 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +from tangle_cli.generated.models import ComponentSpec as GeneratedComponentSpec from tangle_cli.models import ( ComponentInfo, ComponentSpec, @@ -54,6 +55,10 @@ def test_failed_execution_ids(self): 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 From ac46e4e420d8b6ab8c80131434ca0e133a101572 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 18:15:02 -0700 Subject: [PATCH 018/111] refactor: extend generated execution details model --- README.md | 5 ++- tangle_cli/client.py | 11 +++--- tangle_cli/generated/models.py | 4 +- tangle_cli/generated_model_extensions.py | 44 ++++++++++++++++++++++ tangle_cli/models.py | 41 ++------------------ tests/test_models.py | 15 ++++++-- tests/test_static_client.py | 2 + tests/test_tangle_deploy_compat_imports.py | 3 +- 8 files changed, 71 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 8c6f954..8d528d0 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,9 @@ OpenAPI schema. Handwritten semantic helpers such as `find_existing_components(...)` return domain models; that helper accepts component specs, mapping references, or plain names plus optional names/digests and publisher filters, and returns a de-duplicated `list[ComponentInfo]`. -`ComponentSpec` is the generated OpenAPI model extended with YAML parsing and -legacy convenience helpers, and remains re-exported from `tangle_cli.models`. +`ComponentSpec` is a generated OpenAPI model extended with legacy convenience +helpers, and remains re-exported from `tangle_cli.models`. Execution detail +helpers use the generated `GetExecutionInfoResponse` model directly. To refresh the checked-in generated methods/models from the official Tangle backend submodule, run: diff --git a/tangle_cli/client.py b/tangle_cli/client.py index a8efefa..aa85536 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -22,12 +22,11 @@ default_base_url, default_token, ) +from .generated.models import ComponentSpec, GetExecutionInfoResponse from .generated.operations import GeneratedTangleApiOperations from .logger import Logger, _null_logger from .models import ( ComponentInfo, - ComponentSpec, - ExecutionDetails, GraphExecutionState, PipelineRun, RunDetails, @@ -198,8 +197,8 @@ def _decode_response(response: requests.Response) -> Any: # ---- Handwritten semantic helpers consumed by tangle-deploy ---------- - def get_execution_details(self, execution_id: str) -> ExecutionDetails: - details = ExecutionDetails.from_dict(_to_plain(self.executions_details(execution_id))) + def get_execution_details(self, execution_id: str) -> GetExecutionInfoResponse: + details = self.executions_details(execution_id) self._enrich_execution_tree(details) return details @@ -389,14 +388,14 @@ def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None: details = self.get_run_details(run_id, include_implementations=True) return details.execution.task_spec if details.execution else None - def _enrich_execution_tree(self, execution: ExecutionDetails) -> None: + 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 for task_name, child_execution_id in child_ids.items(): if not child_execution_id: continue - child = ExecutionDetails.from_dict(_to_plain(self.executions_details(child_execution_id))) + 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) diff --git a/tangle_cli/generated/models.py b/tangle_cli/generated/models.py index ed6499b..3baaf3f 100644 --- a/tangle_cli/generated/models.py +++ b/tangle_cli/generated/models.py @@ -14,7 +14,7 @@ except ImportError: # pragma: no cover - pydantic v1 fallback ConfigDict = None # type: ignore[assignment] -from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetGraphExecutionStateResponseExtensions +from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetExecutionInfoResponseExtensions, GetGraphExecutionStateResponseExtensions class TangleGeneratedModel(BaseModel): @@ -184,7 +184,7 @@ class GetExecutionArtifactsResponse(TangleGeneratedModel): input_artifacts: Any = None output_artifacts: Any = None -class GetExecutionInfoResponse(TangleGeneratedModel): +class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, TangleGeneratedModel): child_task_execution_ids: Any = None id: Any = None input_artifacts: Any = None diff --git a/tangle_cli/generated_model_extensions.py b/tangle_cli/generated_model_extensions.py index 2fe298b..fa67481 100644 --- a/tangle_cli/generated_model_extensions.py +++ b/tangle_cli/generated_model_extensions.py @@ -322,6 +322,49 @@ def ensure_digest(self) -> str | 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.""" @@ -352,5 +395,6 @@ def failed_execution_ids(self) -> list[str]: MODEL_EXTENSIONS = { "ComponentSpec": "ComponentSpecExtensions", + "GetExecutionInfoResponse": "GetExecutionInfoResponseExtensions", "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", } diff --git a/tangle_cli/models.py b/tangle_cli/models.py index 7b8549e..0290a47 100644 --- a/tangle_cli/models.py +++ b/tangle_cli/models.py @@ -2,7 +2,7 @@ API-contract dataclasses for the Tangle (Oasis) Cloud Pipelines API. These dataclasses model the shapes of HTTP request/response bodies on the -Tangle API — ``PipelineRun``, ``ExecutionDetails``, ``ComponentSpec``, +Tangle API — ``PipelineRun``, ``ComponentSpec``, container state, artifacts, etc. They are used by wrapper packages and OpenAPI-backed client helpers. """ @@ -11,7 +11,7 @@ from dataclasses import asdict, dataclass, field from typing import Any -from tangle_cli.generated.models import ComponentSpec +from tangle_cli.generated.models import ComponentSpec, GetExecutionInfoResponse # ---- Helpers --------------------------------------------------------------- @@ -199,40 +199,6 @@ def strip_implementations(self) -> None: self.component_spec.strip_implementation() -@dataclass -class ExecutionDetails: - """Response from GET /api/executions/{id}/details.""" - id: str - task_spec: TaskSpec = field(default_factory=TaskSpec) - child_executions: dict[str, ExecutionDetails] = field(default_factory=dict) - pipeline_run_id: str | None = None - input_artifacts: dict[str, str] = field(default_factory=dict) - output_artifacts: dict[str, str] = field(default_factory=dict) - raw: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> ExecutionDetails: - return cls( - id=data.get("id", ""), - task_spec=TaskSpec.from_dict(data.get("task_spec", {})), - pipeline_run_id=data.get("pipeline_run_id"), - input_artifacts={k: v["id"] for k, v in data.get("input_artifacts", {}).items() if "id" in v}, - output_artifacts={k: v["id"] for k, v in data.get("output_artifacts", {}).items() if "id" in v}, - raw=data, - ) - - def strip_implementations(self) -> None: - """Remove implementation blocks from all component specs in-place.""" - self.task_spec.strip_implementations() - for child in self.child_executions.values(): - child.strip_implementations() - - @property - def tasks(self) -> dict[str, TaskSpec]: - """Shortcut to the root task_spec's graph_tasks.""" - return self.task_spec.graph_tasks - - # ---- Container state ------------------------------------------------------- @@ -322,7 +288,7 @@ def from_dict(cls, data: dict[str, Any]) -> ContainerState: class RunDetails: """Combined pipeline run + execution details from get_run_details.""" run: PipelineRun - execution: ExecutionDetails | None = None + execution: GetExecutionInfoResponse | None = None annotations: dict[str, str | None] | None = None execution_state: GraphExecutionState | None = None @@ -488,7 +454,6 @@ def _dataclass_getitem(self, key: str) -> Any: GraphExecutionState, PipelineRun, TaskSpec, - ExecutionDetails, KubernetesDebugInfo, KubernetesJobInfo, DebugInfo, diff --git a/tests/test_models.py b/tests/test_models.py index ffd6631..85623d6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,12 +2,14 @@ from __future__ import annotations -from tangle_cli.generated.models import ComponentSpec as GeneratedComponentSpec +from tangle_cli.generated.models import ( + ComponentSpec as GeneratedComponentSpec, + GetExecutionInfoResponse, +) from tangle_cli.models import ( ComponentInfo, ComponentSpec, ContainerState, - ExecutionDetails, GraphExecutionState, PipelineRun, SecretInfo, @@ -191,9 +193,12 @@ def test_add_official_prefix_idempotent(self): assert add_official_prefix("") == "" -class TestExecutionDetails: +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 = ExecutionDetails.from_dict({ + ed = GetExecutionInfoResponse.from_dict({ "id": "exec-1", "task_spec": {"componentRef": {"spec": {"name": "task"}}}, "input_artifacts": {"in1": {"id": "art-1"}}, @@ -203,3 +208,5 @@ def test_from_dict_parses_artifacts(self): 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_static_client.py b/tests/test_static_client.py index 50a6b09..0146a88 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -8,6 +8,7 @@ from tangle_cli import TangleApiClient from tangle_cli.logger import CaptureLogger from tangle_cli.generated.models import ( + GetExecutionInfoResponse, GetGraphExecutionStateResponse, ListPublishedComponentsResponse, PipelineRunResponse, @@ -154,6 +155,7 @@ def test_get_run_details_uses_native_operations_for_retained_semantic_helper() - 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 diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 3e73c86..add700f 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -35,7 +35,6 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: ComponentSpec, ContainerState, DebugInfo, - ExecutionDetails, GraphExecutionState, KubernetesDebugInfo, KubernetesJobInfo, @@ -105,7 +104,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert _null_logger is not None assert callable(_print_result) assert ArtifactComponentQuery and ArtifactInfo and ComponentInfo - assert ContainerState and DebugInfo and ExecutionDetails and GraphExecutionState + 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) From b5afa98dd43f739c4b4005573b6a0a825895dd78 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 18:40:11 -0700 Subject: [PATCH 019/111] fix: enrich raw run details execution tasks --- tangle_cli/client.py | 80 +++++++++++++++++++++++- tests/test_static_client.py | 120 ++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 3 deletions(-) diff --git a/tangle_cli/client.py b/tangle_cli/client.py index aa85536..4eb9e4b 100644 --- a/tangle_cli/client.py +++ b/tangle_cli/client.py @@ -367,6 +367,7 @@ def get_run_details( root_execution_id = execution_id or run.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(run_id) if include_annotations else None annotations = raw_annotations if isinstance(raw_annotations, dict) else None @@ -392,17 +393,90 @@ 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["execution_id"] = child.id - task.raw["input_artifacts"] = child.input_artifacts - task.raw["output_artifacts"] = child.output_artifacts + 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: diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 0146a88..5491c14 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -168,6 +168,126 @@ def test_get_run_details_uses_native_operations_for_retained_semantic_helper() - ] +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_secret_native_operation_uses_static_generated_endpoint_shape() -> None: session = FakeSession([ response({ From 84023b5314f05a8287f9a11f66d97580109da08d Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 19:55:20 -0700 Subject: [PATCH 020/111] refactor: split native generated API package --- README.md | 28 ++++-- packages/tangle-api/pyproject.toml | 21 +++++ .../tangle-api/src/tangle_api}/__init__.py | 0 .../src/tangle_api}/generated/__init__.py | 0 .../src/tangle_api}/generated/models.py | 37 +------- .../src/tangle_api}/generated/operations.py | 0 .../tangle-api/src/tangle_api}/py.typed | 0 .../tangle-cli/src/tangle_cli/__init__.py | 11 +++ .../tangle-cli/src/tangle_cli}/api_cli.py | 0 .../tangle-cli/src/tangle_cli}/api_schema.py | 0 .../src/tangle_cli}/api_transport.py | 0 .../tangle-cli/src/tangle_cli}/cli.py | 0 .../tangle-cli/src/tangle_cli}/client.py | 6 +- .../src/tangle_cli}/component_inspector.py | 0 .../src/tangle_cli}/components_cli.py | 0 .../tangle_cli}/dynamic_discovery_client.py | 0 .../tangle_cli}/generated_model_extensions.py | 0 .../src/tangle_cli/generated_runtime.py | 43 +++++++++ .../tangle-cli/src/tangle_cli}/logger.py | 0 .../tangle-cli/src/tangle_cli}/models.py | 2 +- .../src/tangle_cli/openapi/__init__.py | 0 .../src/tangle_cli}/openapi/codegen.py | 46 ++------- .../src/tangle_cli}/openapi/openapi.json | 0 .../src/tangle_cli}/openapi/parser.py | 0 .../tangle_cli}/published_components_cli.py | 46 +++++++-- packages/tangle-cli/src/tangle_cli/py.typed | 0 .../tangle-cli/src/tangle_cli}/utils.py | 0 pyproject.toml | 12 ++- tangle_cli/__init__.py | 6 -- tests/test_codegen.py | 1 + tests/test_models.py | 2 +- tests/test_packaging.py | 94 +++++++++++++++++++ tests/test_static_client.py | 4 +- tests/test_tangle_deploy_compat_imports.py | 8 +- uv.lock | 36 ++++++- 35 files changed, 291 insertions(+), 112 deletions(-) create mode 100644 packages/tangle-api/pyproject.toml rename {tangle_cli/openapi => packages/tangle-api/src/tangle_api}/__init__.py (100%) rename {tangle_cli => packages/tangle-api/src/tangle_api}/generated/__init__.py (100%) rename {tangle_cli => packages/tangle-api/src/tangle_api}/generated/models.py (76%) rename {tangle_cli => packages/tangle-api/src/tangle_api}/generated/operations.py (100%) rename {tangle_cli => packages/tangle-api/src/tangle_api}/py.typed (100%) create mode 100644 packages/tangle-cli/src/tangle_cli/__init__.py rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/api_cli.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/api_schema.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/api_transport.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/cli.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/client.py (98%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/component_inspector.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/components_cli.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/dynamic_discovery_client.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/generated_model_extensions.py (100%) create mode 100644 packages/tangle-cli/src/tangle_cli/generated_runtime.py rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/logger.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/models.py (99%) create mode 100644 packages/tangle-cli/src/tangle_cli/openapi/__init__.py rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/openapi/codegen.py (93%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/openapi/openapi.json (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/openapi/parser.py (100%) rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/published_components_cli.py (77%) create mode 100644 packages/tangle-cli/src/tangle_cli/py.typed rename {tangle_cli => packages/tangle-cli/src/tangle_cli}/utils.py (100%) delete mode 100644 tangle_cli/__init__.py create mode 100644 tests/test_packaging.py diff --git a/README.md b/README.md index 8d528d0..db7ed8e 100644 --- a/README.md +++ b/README.md @@ -184,14 +184,25 @@ existing = client.find_existing_components( ``` `TangleApiClient` uses checked-in endpoint methods generated offline from -`tangle_cli/openapi/openapi.json`, so normal imports do not fetch or parse the -OpenAPI schema. Handwritten semantic helpers such as +`tangle_cli/openapi/openapi.json` into the native `tangle_api.generated` package, +so normal imports do not fetch or parse the OpenAPI schema. Handwritten semantic +helpers such as `find_existing_components(...)` return domain models; that helper accepts component specs, mapping references, or plain names plus optional names/digests and publisher filters, and returns a de-duplicated `list[ComponentInfo]`. `ComponentSpec` is a generated OpenAPI model extended with legacy convenience helpers, and remains re-exported from `tangle_cli.models`. Execution detail -helpers use the generated `GetExecutionInfoResponse` model directly. +helpers use the generated `GetExecutionInfoResponse` model directly. The top-level +`import tangle_cli` is lightweight and does not import native static bindings; +install the `native` extra or otherwise provide a local `tangle_api.generated` +package before importing `tangle_cli.client`. + +The repository is split into two import packages: `tangle_cli` contains the CLI, +business helpers, dynamic discovery, codegen, runtime base classes, and default +model extensions; `tangle_api` contains only the native checked-in generated +models and operation proxies for the official OSS API. Downstream consumers that +vendor `tangle_cli` can generate their own local `tangle_api.generated` package +from their schema without vendoring cli-lab's native generated package. To refresh the checked-in generated methods/models from the official Tangle backend submodule, run: @@ -205,14 +216,14 @@ uv run pytest With no source flags, codegen loads OpenAPI from the default official backend submodule at `third_party/tangle`, writes `tangle_cli/openapi/openapi.json`, and -regenerates `tangle_cli/generated`. The backend import creates a database engine +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. If the submodule is missing, initialize it with `git submodule update --init --recursive`. `--out` controls where generated support modules are written. It defaults to -`tangle_cli/generated`, which is the package support module used by the public -`tangle_cli/client.py` wrapper. `--operations-class-name` controls the generated +`packages/tangle-api/src/tangle_api/generated`, which is the native generated +package used by the public `tangle_cli/client.py` wrapper. `--operations-class-name` controls the generated operations class name in `/operations.py`; it defaults to `GeneratedTangleApiOperations`. `--model-extension-module` points codegen at an importable module with a `MODEL_EXTENSIONS` mapping from generated model class @@ -236,6 +247,7 @@ generated model classes. Codegen writes exactly these support files: /operations.py ``` +The generated models import shared runtime helpers from `tangle_cli.generated_runtime`. The public client remains handwritten at `tangle_cli/client.py`; codegen does not create a default generated public client wrapper. @@ -249,7 +261,7 @@ uv run python -m tangle_cli.openapi.codegen --from-snapshot If you already have a remote OpenAPI JSON document, fetch that directly instead: ```bash -uv run python -m tangle_cli.openapi.codegen --openapi-url https://example.com/openapi.json --out tangle_cli/generated +uv run python -m tangle_cli.openapi.codegen --openapi-url https://example.com/openapi.json --out src/tangle_api/generated ``` For example, the raw GitHub snapshot form is expressible as: @@ -263,7 +275,7 @@ Downstream tools can point `--out` at their own generated support package, e.g.: ```bash uv run python -m tangle_cli.openapi.codegen \ --openapi-url https://oasis.shopify.io/openapi.json \ - --out src/tangle_deploy/generated_api \ + --out src/tangle_api/generated \ --operations-class-name GeneratedTangleApiExtensions \ --model-extension-module tangle_deploy.tangle_api_model_extensions ``` diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml new file mode 100644 index 0000000..8e4644c --- /dev/null +++ b/packages/tangle-api/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "tangle-api" +version = "0.0.1" +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.0.1", +] + +[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/openapi/__init__.py b/packages/tangle-api/src/tangle_api/__init__.py similarity index 100% rename from tangle_cli/openapi/__init__.py rename to packages/tangle-api/src/tangle_api/__init__.py diff --git a/tangle_cli/generated/__init__.py b/packages/tangle-api/src/tangle_api/generated/__init__.py similarity index 100% rename from tangle_cli/generated/__init__.py rename to packages/tangle-api/src/tangle_api/generated/__init__.py diff --git a/tangle_cli/generated/models.py b/packages/tangle-api/src/tangle_api/generated/models.py similarity index 76% rename from tangle_cli/generated/models.py rename to packages/tangle-api/src/tangle_api/generated/models.py index 3baaf3f..9f1ca60 100644 --- a/tangle_cli/generated/models.py +++ b/packages/tangle-api/src/tangle_api/generated/models.py @@ -7,43 +7,12 @@ from typing import Any -from pydantic import BaseModel, Field +from pydantic import Field -try: - from pydantic import ConfigDict -except ImportError: # pragma: no cover - pydantic v1 fallback - ConfigDict = None # type: ignore[assignment] +from tangle_cli.generated_runtime import TangleGeneratedModel from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetExecutionInfoResponseExtensions, GetGraphExecutionStateResponseExtensions - -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) - class ArtifactData(TangleGeneratedModel): created_at: Any = None deleted_at: Any = None @@ -324,4 +293,4 @@ class ValidationError(TangleGeneratedModel): msg: Any = None type: Any = None -__all__ = ['TangleGeneratedModel', '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'] +__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/tangle_cli/generated/operations.py b/packages/tangle-api/src/tangle_api/generated/operations.py similarity index 100% rename from tangle_cli/generated/operations.py rename to packages/tangle-api/src/tangle_api/generated/operations.py diff --git a/tangle_cli/py.typed b/packages/tangle-api/src/tangle_api/py.typed similarity index 100% rename from tangle_cli/py.typed rename to packages/tangle-api/src/tangle_api/py.typed 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..9bbe1bb --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -0,0 +1,11 @@ +"""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 tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient + +__all__ = ["TangleDynamicDiscoveryClient"] diff --git a/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py similarity index 100% rename from tangle_cli/api_cli.py rename to packages/tangle-cli/src/tangle_cli/api_cli.py diff --git a/tangle_cli/api_schema.py b/packages/tangle-cli/src/tangle_cli/api_schema.py similarity index 100% rename from tangle_cli/api_schema.py rename to packages/tangle-cli/src/tangle_cli/api_schema.py diff --git a/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py similarity index 100% rename from tangle_cli/api_transport.py rename to packages/tangle-cli/src/tangle_cli/api_transport.py diff --git a/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py similarity index 100% rename from tangle_cli/cli.py rename to packages/tangle-cli/src/tangle_cli/cli.py diff --git a/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py similarity index 98% rename from tangle_cli/client.py rename to packages/tangle-cli/src/tangle_cli/client.py index 4eb9e4b..a476b6c 100644 --- a/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -1,7 +1,7 @@ """Static public Tangle API client. ``TangleApiClient`` is the stable wrapper class consumed by downstream tools. -Endpoint methods are generated offline into :mod:`tangle_cli.generated.operations` +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. """ @@ -22,8 +22,8 @@ default_base_url, default_token, ) -from .generated.models import ComponentSpec, GetExecutionInfoResponse -from .generated.operations import GeneratedTangleApiOperations +from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse +from tangle_api.generated.operations import GeneratedTangleApiOperations from .logger import Logger, _null_logger from .models import ( ComponentInfo, diff --git a/tangle_cli/component_inspector.py b/packages/tangle-cli/src/tangle_cli/component_inspector.py similarity index 100% rename from tangle_cli/component_inspector.py rename to packages/tangle-cli/src/tangle_cli/component_inspector.py diff --git a/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py similarity index 100% rename from tangle_cli/components_cli.py rename to packages/tangle-cli/src/tangle_cli/components_cli.py diff --git a/tangle_cli/dynamic_discovery_client.py b/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py similarity index 100% rename from tangle_cli/dynamic_discovery_client.py rename to packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py diff --git a/tangle_cli/generated_model_extensions.py b/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py similarity index 100% rename from tangle_cli/generated_model_extensions.py rename to packages/tangle-cli/src/tangle_cli/generated_model_extensions.py 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/tangle_cli/logger.py b/packages/tangle-cli/src/tangle_cli/logger.py similarity index 100% rename from tangle_cli/logger.py rename to packages/tangle-cli/src/tangle_cli/logger.py diff --git a/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py similarity index 99% rename from tangle_cli/models.py rename to packages/tangle-cli/src/tangle_cli/models.py index 0290a47..6133bd1 100644 --- a/tangle_cli/models.py +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -11,7 +11,7 @@ from dataclasses import asdict, dataclass, field from typing import Any -from tangle_cli.generated.models import ComponentSpec, GetExecutionInfoResponse +from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse # ---- Helpers --------------------------------------------------------------- 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/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py similarity index 93% rename from tangle_cli/openapi/codegen.py rename to packages/tangle-cli/src/tangle_cli/openapi/codegen.py index 57d5941..abe919d 100644 --- a/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -25,8 +25,8 @@ from .parser import DEFAULT_OPENAPI_PATH, load_openapi_schema, parsed_operations -_REPO_ROOT = Path(__file__).resolve().parents[2] -_GENERATED_DIR = _REPO_ROOT / "tangle_cli" / "generated" +_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" @@ -223,12 +223,9 @@ def generate_models( "", "from typing import Any", "", - "from pydantic import BaseModel, Field", + "from pydantic import Field", "", - "try:", - " from pydantic import ConfigDict", - "except ImportError: # pragma: no cover - pydantic v1 fallback", - " ConfigDict = None # type: ignore[assignment]", + "from tangle_cli.generated_runtime import TangleGeneratedModel", "", ] @@ -247,38 +244,7 @@ def generate_models( imports = ", ".join(sorted(set(used_extensions.values()))) lines.extend([f"from {model_extension_module} import {imports}", ""]) - lines.extend([ - "", - "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)", - "", - ]) - - exports = ["TangleGeneratedModel"] + 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) @@ -590,7 +556,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument( "--out", default=str(_GENERATED_DIR), - help="Generated support module directory (default: tangle_cli/generated).", + help="Generated support module directory (default: packages/tangle-api/src/tangle_api/generated).", ) parser.add_argument( "--operations-class-name", diff --git a/tangle_cli/openapi/openapi.json b/packages/tangle-cli/src/tangle_cli/openapi/openapi.json similarity index 100% rename from tangle_cli/openapi/openapi.json rename to packages/tangle-cli/src/tangle_cli/openapi/openapi.json diff --git a/tangle_cli/openapi/parser.py b/packages/tangle-cli/src/tangle_cli/openapi/parser.py similarity index 100% rename from tangle_cli/openapi/parser.py rename to packages/tangle-cli/src/tangle_cli/openapi/parser.py diff --git a/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py similarity index 77% rename from tangle_cli/published_components_cli.py rename to packages/tangle-cli/src/tangle_cli/published_components_cli.py index c716ce5..7e08bf8 100644 --- a/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -3,18 +3,11 @@ from __future__ import annotations import json -from typing import Annotated +from typing import Annotated, Any from cyclopts import App, Parameter from .api_transport import DEFAULT_TIMEOUT_SECONDS -from .client import TangleApiClient -from .component_inspector import ( - get_standard_library, - inspect_by_digest, - inspect_by_name, - search_components, -) BaseUrlOption = Annotated[ str | None, @@ -54,9 +47,20 @@ def _client_from_options( token: str | None = None, auth_header: str | None = None, header: list[str] | None = None, -) -> TangleApiClient: +) -> Any: """Create the static client used by published-component commands.""" + try: + from .client import TangleApiClient + except ModuleNotFoundError as exc: + if exc.name == "tangle_api": + raise SystemExit( + "Native generated Tangle API bindings are required for " + "published-component commands. Install tangle-cli[native] " + "or provide a local tangle_api.generated package." + ) from exc + raise + return TangleApiClient( base_url=base_url, token=token, @@ -70,6 +74,30 @@ def _print_json(payload: object) -> None: print(json.dumps(payload, indent=2, sort_keys=True)) +def search_components(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import search_components as _search_components + + return _search_components(*args, **kwargs) + + +def inspect_by_digest(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import inspect_by_digest as _inspect_by_digest + + return _inspect_by_digest(*args, **kwargs) + + +def inspect_by_name(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import inspect_by_name as _inspect_by_name + + return _inspect_by_name(*args, **kwargs) + + +def get_standard_library(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import get_standard_library as _get_standard_library + + return _get_standard_library(*args, **kwargs) + + @app.command(name="search") def published_components_search( name: str | None = None, 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/tangle_cli/utils.py b/packages/tangle-cli/src/tangle_cli/utils.py similarity index 100% rename from tangle_cli/utils.py rename to packages/tangle-cli/src/tangle_cli/utils.py diff --git a/pyproject.toml b/pyproject.toml index 9add95b..8018e06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ 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.0.1"] + [project.scripts] tangle = "tangle_cli.cli:main" @@ -33,11 +36,15 @@ 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"] @@ -45,6 +52,7 @@ testpaths = ["tests"] [dependency-groups] dev = [ "pytest>=9.0.2", + "tangle-api", ] codegen = [ # Mirrors third_party/tangle backend import requirements used by diff --git a/tangle_cli/__init__.py b/tangle_cli/__init__.py deleted file mode 100644 index 72fc5bd..0000000 --- a/tangle_cli/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""tangle-cli public API.""" - -from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient -from tangle_cli.client import TangleApiClient - -__all__ = ["TangleApiClient", "TangleDynamicDiscoveryClient"] diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 6b48163..c58feea 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -329,6 +329,7 @@ def test_generate_models_uses_builtin_model_extension_module_by_default() -> Non }, }) + assert "from tangle_cli.generated_runtime import TangleGeneratedModel" in models assert ( "from tangle_cli.generated_model_extensions import " "GetGraphExecutionStateResponseExtensions" diff --git a/tests/test_models.py b/tests/test_models.py index 85623d6..654de05 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from tangle_cli.generated.models import ( +from tangle_api.generated.models import ( ComponentSpec as GeneratedComponentSpec, GetExecutionInfoResponse, ) diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..80fcb6c --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import zipfile +from pathlib import Path + + +_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() + (path / "httpx.py").write_text("", encoding="utf-8") + (path / "platformdirs.py").write_text("", encoding="utf-8") + (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 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 = archive.read("tangle_cli-0.0.1.dist-info/METADATA").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 "Requires-Dist: tangle-api==0.0.1" not in requires_dist + assert "Requires-Dist: tangle-api==0.0.1 ; 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_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") + env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} + + subprocess.run( + [ + sys.executable, + "-c", + "from tangle_cli.client import TangleApiClient; " + "assert TangleApiClient.__name__ == 'TangleApiClient'", + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 5491c14..9763a2c 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -5,9 +5,9 @@ import requests -from tangle_cli import TangleApiClient +from tangle_cli.client import TangleApiClient from tangle_cli.logger import CaptureLogger -from tangle_cli.generated.models import ( +from tangle_api.generated.models import ( GetExecutionInfoResponse, GetGraphExecutionStateResponse, ListPublishedComponentsResponse, diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index add700f..a7ec8e3 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -7,7 +7,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: import tangle_cli - from tangle_cli import TangleApiClient, TangleDynamicDiscoveryClient, utils as utils_module + 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.component_inspector import ( @@ -87,10 +87,10 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: ) assert TangleDynamicDiscoveryClient.__name__ == DynamicDiscoveryClient.__name__ == "TangleDynamicDiscoveryClient" - assert TangleApiClient.__name__ == StaticClient.__name__ == "TangleApiClient" - assert hasattr(tangle_cli, "TangleApiClient") + assert StaticClient.__name__ == "TangleApiClient" + assert not hasattr(tangle_cli, "TangleApiClient") assert importlib.util.find_spec("tangle_cli.client") is not None - assert callable(TangleApiClient("https://api.test").set_verbose) + assert callable(StaticClient("https://api.test").set_verbose) assert ComponentSpec.__name__ == "ComponentSpec" assert PipelineRun.__name__ == "PipelineRun" assert callable(get_standard_library) diff --git a/uv.lock b/uv.lock index e60beff..87a8e41 100644 --- a/uv.lock +++ b/uv.lock @@ -8,9 +8,15 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-04T01:22:41.228517Z" +exclude-newer = "2026-06-05T03:12:22.361582Z" exclude-newer-span = "P7D" +[manifest] +members = [ + "tangle-api", + "tangle-cli", +] + [[package]] name = "alembic" version = "1.18.4" @@ -1767,6 +1773,21 @@ 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.0.1" +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" @@ -1782,6 +1803,11 @@ dependencies = [ { name = "requests" }, ] +[package.optional-dependencies] +native = [ + { name = "tangle-api" }, +] + [package.dev-dependencies] codegen = [ { name = "alembic" }, @@ -1798,6 +1824,7 @@ codegen = [ ] dev = [ { name = "pytest" }, + { name = "tangle-api" }, ] [package.metadata] @@ -1810,7 +1837,9 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.0" }, + { name = "tangle-api", marker = "extra == 'native'", editable = "packages/tangle-api" }, ] +provides-extras = ["native"] [package.metadata.requires-dev] codegen = [ @@ -1826,7 +1855,10 @@ codegen = [ { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, { name = "sqlalchemy", specifier = ">=2.0.49" }, ] -dev = [{ name = "pytest", specifier = ">=9.0.2" }] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "tangle-api", editable = "packages/tangle-api" }, +] [[package]] name = "tomli" From 28a3d582872637a115029bc150f26404d571b72e Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 20:00:06 -0700 Subject: [PATCH 021/111] feat: compose generated model extensions --- README.md | 28 +- .../src/tangle_api/generated/models.py | 290 ++++++++++++++---- .../src/tangle_cli/openapi/codegen.py | 175 +++++++++-- tests/test_codegen.py | 153 ++++++++- 4 files changed, 547 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index db7ed8e..0bf3215 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,11 @@ package used by the public `tangle_cli/client.py` wrapper. `--operations-class-n operations class name in `/operations.py`; it defaults to `GeneratedTangleApiOperations`. `--model-extension-module` points codegen at an importable module with a `MODEL_EXTENSIONS` mapping from generated model class -names to extension class names. It defaults to -`tangle_cli.generated_model_extensions`; pass an empty string (`--model-extension-module ""`) -to disable extensions. Matching generated models inherit those extensions before -`TangleGeneratedModel`, e.g.: +names to extension class names. The built-in `tangle_cli.generated_model_extensions` +module is applied first by default, and repeated `--model-extension-module` +values are applied after it in order. Pass an empty string +(`--model-extension-module ""`) to disable the default module. Generated object +models are emitted as private schema-derived bases plus public model classes, e.g.: ```python MODEL_EXTENSIONS = { @@ -238,8 +239,23 @@ MODEL_EXTENSIONS = { } ``` -The extension classes must be importable from that module and should not import -generated model classes. Codegen writes exactly these support files: +```python +class _ComponentSpecGenerated(TangleGeneratedModel): + name: Any = None + +class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): + pass +``` + +For models without extensions, codegen still emits a public subclass such as +`class OtherResponse(_OtherResponseGenerated): pass` so the exported class keeps +its public OpenAPI name. When multiple extension modules target the same model, +later/downstream extensions are leftmost in the public class MRO and override +earlier/default extensions while schema-derived data remains available via +`to_dict()`. Duplicate extension class names from different modules are imported +with deterministic aliases. Extension classes must be importable from their +modules and should not import generated model classes. Codegen writes exactly +these support files: ```text /__init__.py diff --git a/packages/tangle-api/src/tangle_api/generated/models.py b/packages/tangle-api/src/tangle_api/generated/models.py index 9f1ca60..b6cbe7d 100644 --- a/packages/tangle-api/src/tangle_api/generated/models.py +++ b/packages/tangle-api/src/tangle_api/generated/models.py @@ -13,7 +13,7 @@ from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetExecutionInfoResponseExtensions, GetGraphExecutionStateResponseExtensions -class ArtifactData(TangleGeneratedModel): +class _ArtifactDataGenerated(TangleGeneratedModel): created_at: Any = None deleted_at: Any = None extra_data: Any = None @@ -23,16 +23,25 @@ class ArtifactData(TangleGeneratedModel): uri: Any = None value: Any = None -class ArtifactDataResponse(TangleGeneratedModel): +class ArtifactData(_ArtifactDataGenerated): + pass + +class _ArtifactDataResponseGenerated(TangleGeneratedModel): is_dir: Any = None total_size: Any = None uri: Any = None value: Any = None -class ArtifactNodeIdResponse(TangleGeneratedModel): +class ArtifactDataResponse(_ArtifactDataResponseGenerated): + pass + +class _ArtifactNodeIdResponseGenerated(TangleGeneratedModel): id: Any = None -class ArtifactNodeResponse(TangleGeneratedModel): +class ArtifactNodeIdResponse(_ArtifactNodeIdResponseGenerated): + pass + +class _ArtifactNodeResponseGenerated(TangleGeneratedModel): artifact_data: Any = None id: Any = None producer_execution_id: Any = None @@ -40,35 +49,59 @@ class ArtifactNodeResponse(TangleGeneratedModel): type_name: Any = None type_properties: Any = None -class BodyCreateApiPipelineRunsPost(TangleGeneratedModel): +class ArtifactNodeResponse(_ArtifactNodeResponseGenerated): + pass + +class _BodyCreateApiPipelineRunsPostGenerated(TangleGeneratedModel): annotations: Any = None components: Any = None root_task: Any = None -class BodyCreateSecretApiSecretsPost(TangleGeneratedModel): +class BodyCreateApiPipelineRunsPost(_BodyCreateApiPipelineRunsPostGenerated): + pass + +class _BodyCreateSecretApiSecretsPostGenerated(TangleGeneratedModel): secret_value: Any = None -class BodySetSettingsApiUsersMeSettingsPatch(TangleGeneratedModel): +class BodyCreateSecretApiSecretsPost(_BodyCreateSecretApiSecretsPostGenerated): + pass + +class _BodySetSettingsApiUsersMeSettingsPatchGenerated(TangleGeneratedModel): settings: Any = None -class BodyUpdateSecretApiSecretsSecretNamePut(TangleGeneratedModel): +class BodySetSettingsApiUsersMeSettingsPatch(_BodySetSettingsApiUsersMeSettingsPatchGenerated): + pass + +class _BodyUpdateSecretApiSecretsSecretNamePutGenerated(TangleGeneratedModel): secret_value: Any = None -class CachingStrategySpec(TangleGeneratedModel): +class BodyUpdateSecretApiSecretsSecretNamePut(_BodyUpdateSecretApiSecretsSecretNamePutGenerated): + pass + +class _CachingStrategySpecGenerated(TangleGeneratedModel): maxcachestaleness: Any = Field(default=None, alias='maxCacheStaleness') -class ComponentLibrary(TangleGeneratedModel): +class CachingStrategySpec(_CachingStrategySpecGenerated): + pass + +class _ComponentLibraryGenerated(TangleGeneratedModel): annotations: Any = None name: Any = None root_folder: Any = None -class ComponentLibraryFolder(TangleGeneratedModel): +class ComponentLibrary(_ComponentLibraryGenerated): + pass + +class _ComponentLibraryFolderGenerated(TangleGeneratedModel): annotations: Any = None components: Any = None folders: Any = None name: Any = None -class ComponentLibraryResponse(TangleGeneratedModel): +class ComponentLibraryFolder(_ComponentLibraryFolderGenerated): + pass + +class _ComponentLibraryResponseGenerated(TangleGeneratedModel): annotations: Any = None component_count: Any = None created_at: Any = None @@ -79,7 +112,10 @@ class ComponentLibraryResponse(TangleGeneratedModel): root_folder: Any = None updated_at: Any = None -class ComponentReference(TangleGeneratedModel): +class ComponentLibraryResponse(_ComponentLibraryResponseGenerated): + pass + +class _ComponentReferenceGenerated(TangleGeneratedModel): digest: Any = None name: Any = None spec: Any = None @@ -87,11 +123,17 @@ class ComponentReference(TangleGeneratedModel): text: Any = None url: Any = None -class ComponentResponse(TangleGeneratedModel): +class ComponentReference(_ComponentReferenceGenerated): + pass + +class _ComponentResponseGenerated(TangleGeneratedModel): digest: Any = None text: Any = None -class ComponentSpec(ComponentSpecExtensions, TangleGeneratedModel): +class ComponentResponse(_ComponentResponseGenerated): + pass + +class _ComponentSpecGenerated(TangleGeneratedModel): description: Any = None implementation: Any = None inputs: Any = None @@ -99,49 +141,82 @@ class ComponentSpec(ComponentSpecExtensions, TangleGeneratedModel): name: Any = None outputs: Any = None -class ConcatPlaceholder(TangleGeneratedModel): +class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): + pass + +class _ConcatPlaceholderGenerated(TangleGeneratedModel): concat: Any = None +class ConcatPlaceholder(_ConcatPlaceholderGenerated): + pass + ContainerExecutionStatus = Any -class ContainerImplementation(TangleGeneratedModel): +class _ContainerImplementationGenerated(TangleGeneratedModel): container: Any = None -class ContainerSpec(TangleGeneratedModel): +class ContainerImplementation(_ContainerImplementationGenerated): + pass + +class _ContainerSpecGenerated(TangleGeneratedModel): args: Any = None command: Any = None env: Any = None image: Any = None -class DynamicDataArgument(TangleGeneratedModel): +class ContainerSpec(_ContainerSpecGenerated): + pass + +class _DynamicDataArgumentGenerated(TangleGeneratedModel): dynamicdata: Any = Field(default=None, alias='dynamicData') -class ExecutionNodeReference(TangleGeneratedModel): +class DynamicDataArgument(_DynamicDataArgumentGenerated): + pass + +class _ExecutionNodeReferenceGenerated(TangleGeneratedModel): execution_node_id: Any = None pipeline_run_id: Any = None -class ExecutionOptionsSpec(TangleGeneratedModel): +class ExecutionNodeReference(_ExecutionNodeReferenceGenerated): + pass + +class _ExecutionOptionsSpecGenerated(TangleGeneratedModel): cachingstrategy: Any = Field(default=None, alias='cachingStrategy') retrystrategy: Any = Field(default=None, alias='retryStrategy') -class ExecutionStatusSummary(TangleGeneratedModel): +class ExecutionOptionsSpec(_ExecutionOptionsSpecGenerated): + pass + +class _ExecutionStatusSummaryGenerated(TangleGeneratedModel): ended_executions: Any = None has_ended: Any = None total_executions: Any = None -class GetArtifactInfoResponse(TangleGeneratedModel): +class ExecutionStatusSummary(_ExecutionStatusSummaryGenerated): + pass + +class _GetArtifactInfoResponseGenerated(TangleGeneratedModel): artifact_data: Any = None id: Any = None -class GetArtifactSignedUrlResponse(TangleGeneratedModel): +class GetArtifactInfoResponse(_GetArtifactInfoResponseGenerated): + pass + +class _GetArtifactSignedUrlResponseGenerated(TangleGeneratedModel): signed_url: Any = None -class GetContainerExecutionLogResponse(TangleGeneratedModel): +class GetArtifactSignedUrlResponse(_GetArtifactSignedUrlResponseGenerated): + pass + +class _GetContainerExecutionLogResponseGenerated(TangleGeneratedModel): log_text: Any = None orchestration_error_message: Any = None system_error_exception_full: Any = None -class GetContainerExecutionStateResponse(TangleGeneratedModel): +class GetContainerExecutionLogResponse(_GetContainerExecutionLogResponseGenerated): + pass + +class _GetContainerExecutionStateResponseGenerated(TangleGeneratedModel): debug_info: Any = None ended_at: Any = None execution_nodes_linked_to_same_container_execution: Any = None @@ -149,11 +224,17 @@ class GetContainerExecutionStateResponse(TangleGeneratedModel): started_at: Any = None status: Any = None -class GetExecutionArtifactsResponse(TangleGeneratedModel): +class GetContainerExecutionStateResponse(_GetContainerExecutionStateResponseGenerated): + pass + +class _GetExecutionArtifactsResponseGenerated(TangleGeneratedModel): input_artifacts: Any = None output_artifacts: Any = None -class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, TangleGeneratedModel): +class GetExecutionArtifactsResponse(_GetExecutionArtifactsResponseGenerated): + pass + +class _GetExecutionInfoResponseGenerated(TangleGeneratedModel): child_task_execution_ids: Any = None id: Any = None input_artifacts: Any = None @@ -162,43 +243,76 @@ class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, TangleGenerat pipeline_run_id: Any = None task_spec: Any = None -class GetGraphExecutionStateResponse(GetGraphExecutionStateResponseExtensions, TangleGeneratedModel): +class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, _GetExecutionInfoResponseGenerated): + pass + +class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel): child_execution_status_stats: Any = None child_execution_status_summary: Any = None -class GetUserResponse(TangleGeneratedModel): +class GetGraphExecutionStateResponse(GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated): + pass + +class _GetUserResponseGenerated(TangleGeneratedModel): id: Any = None permissions: Any = None -class GraphImplementation(TangleGeneratedModel): +class GetUserResponse(_GetUserResponseGenerated): + pass + +class _GraphImplementationGenerated(TangleGeneratedModel): graph: Any = None -class GraphInputArgument(TangleGeneratedModel): +class GraphImplementation(_GraphImplementationGenerated): + pass + +class _GraphInputArgumentGenerated(TangleGeneratedModel): graphinput: Any = Field(default=None, alias='graphInput') -class GraphInputReference(TangleGeneratedModel): +class GraphInputArgument(_GraphInputArgumentGenerated): + pass + +class _GraphInputReferenceGenerated(TangleGeneratedModel): inputname: Any = Field(default=None, alias='inputName') type: Any = None -class GraphSpec(TangleGeneratedModel): +class GraphInputReference(_GraphInputReferenceGenerated): + pass + +class _GraphSpecGenerated(TangleGeneratedModel): outputvalues: Any = Field(default=None, alias='outputValues') tasks: Any = None -class HTTPValidationError(TangleGeneratedModel): +class GraphSpec(_GraphSpecGenerated): + pass + +class _HTTPValidationErrorGenerated(TangleGeneratedModel): detail: Any = None -class IfPlaceholder(TangleGeneratedModel): +class HTTPValidationError(_HTTPValidationErrorGenerated): + pass + +class _IfPlaceholderGenerated(TangleGeneratedModel): if_: Any = Field(default=None, alias='if') -class IfPlaceholderStructure(TangleGeneratedModel): +class IfPlaceholder(_IfPlaceholderGenerated): + pass + +class _IfPlaceholderStructureGenerated(TangleGeneratedModel): cond: Any = None else_: Any = Field(default=None, alias='else') then: Any = None -class InputPathPlaceholder(TangleGeneratedModel): +class IfPlaceholderStructure(_IfPlaceholderStructureGenerated): + pass + +class _InputPathPlaceholderGenerated(TangleGeneratedModel): inputpath: Any = Field(default=None, alias='inputPath') -class InputSpec(TangleGeneratedModel): +class InputPathPlaceholder(_InputPathPlaceholderGenerated): + pass + +class _InputSpecGenerated(TangleGeneratedModel): annotations: Any = None default: Any = None description: Any = None @@ -206,39 +320,69 @@ class InputSpec(TangleGeneratedModel): optional: Any = None type: Any = None -class InputValuePlaceholder(TangleGeneratedModel): +class InputSpec(_InputSpecGenerated): + pass + +class _InputValuePlaceholderGenerated(TangleGeneratedModel): inputvalue: Any = Field(default=None, alias='inputValue') -class IsPresentPlaceholder(TangleGeneratedModel): +class InputValuePlaceholder(_InputValuePlaceholderGenerated): + pass + +class _IsPresentPlaceholderGenerated(TangleGeneratedModel): ispresent: Any = Field(default=None, alias='isPresent') -class ListComponentLibrariesResponse(TangleGeneratedModel): +class IsPresentPlaceholder(_IsPresentPlaceholderGenerated): + pass + +class _ListComponentLibrariesResponseGenerated(TangleGeneratedModel): component_libraries: Any = None -class ListPipelineJobsResponse(TangleGeneratedModel): +class ListComponentLibrariesResponse(_ListComponentLibrariesResponseGenerated): + pass + +class _ListPipelineJobsResponseGenerated(TangleGeneratedModel): next_page_token: Any = None pipeline_runs: Any = None -class ListPublishedComponentsResponse(TangleGeneratedModel): +class ListPipelineJobsResponse(_ListPipelineJobsResponseGenerated): + pass + +class _ListPublishedComponentsResponseGenerated(TangleGeneratedModel): published_components: Any = None -class ListSecretsResponse(TangleGeneratedModel): +class ListPublishedComponentsResponse(_ListPublishedComponentsResponseGenerated): + pass + +class _ListSecretsResponseGenerated(TangleGeneratedModel): secrets: Any = None -class MetadataSpec(TangleGeneratedModel): +class ListSecretsResponse(_ListSecretsResponseGenerated): + pass + +class _MetadataSpecGenerated(TangleGeneratedModel): annotations: Any = None labels: Any = None -class OutputPathPlaceholder(TangleGeneratedModel): +class MetadataSpec(_MetadataSpecGenerated): + pass + +class _OutputPathPlaceholderGenerated(TangleGeneratedModel): outputpath: Any = Field(default=None, alias='outputPath') -class OutputSpec(TangleGeneratedModel): +class OutputPathPlaceholder(_OutputPathPlaceholderGenerated): + pass + +class _OutputSpecGenerated(TangleGeneratedModel): annotations: Any = None description: Any = None name: Any = None type: Any = None -class PipelineRunResponse(TangleGeneratedModel): +class OutputSpec(_OutputSpecGenerated): + pass + +class _PipelineRunResponseGenerated(TangleGeneratedModel): annotations: Any = None created_at: Any = None created_by: Any = None @@ -248,7 +392,10 @@ class PipelineRunResponse(TangleGeneratedModel): pipeline_name: Any = None root_execution_id: Any = None -class PublishedComponentResponse(TangleGeneratedModel): +class PipelineRunResponse(_PipelineRunResponseGenerated): + pass + +class _PublishedComponentResponseGenerated(TangleGeneratedModel): deprecated: Any = None digest: Any = None name: Any = None @@ -256,41 +403,68 @@ class PublishedComponentResponse(TangleGeneratedModel): superseded_by: Any = None url: Any = None -class RetryStrategySpec(TangleGeneratedModel): +class PublishedComponentResponse(_PublishedComponentResponseGenerated): + pass + +class _RetryStrategySpecGenerated(TangleGeneratedModel): maxretries: Any = Field(default=None, alias='maxRetries') -class SecretInfoResponse(TangleGeneratedModel): +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 TaskOutputArgument(TangleGeneratedModel): +class SecretInfoResponse(_SecretInfoResponseGenerated): + pass + +class _TaskOutputArgumentGenerated(TangleGeneratedModel): taskoutput: Any = Field(default=None, alias='taskOutput') -class TaskOutputReference(TangleGeneratedModel): +class TaskOutputArgument(_TaskOutputArgumentGenerated): + pass + +class _TaskOutputReferenceGenerated(TangleGeneratedModel): outputname: Any = Field(default=None, alias='outputName') taskid: Any = Field(default=None, alias='taskId') -class TaskSpec(TangleGeneratedModel): +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 UserComponentLibraryPinsResponse(TangleGeneratedModel): +class TaskSpec(_TaskSpecGenerated): + pass + +class _UserComponentLibraryPinsResponseGenerated(TangleGeneratedModel): component_library_ids: Any = None -class UserSettingsResponse(TangleGeneratedModel): +class UserComponentLibraryPinsResponse(_UserComponentLibraryPinsResponseGenerated): + pass + +class _UserSettingsResponseGenerated(TangleGeneratedModel): settings: Any = None -class ValidationError(TangleGeneratedModel): +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-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index abe919d..bc64977 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -20,6 +20,9 @@ import sys import tempfile import urllib.request +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -162,10 +165,47 @@ def _schema_return_annotation(schema: dict[str, Any]) -> str: -def _normalize_model_extension_module(module_name: str | None) -> str | None: - if module_name == "": - return None - return module_name + +@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: @@ -175,12 +215,9 @@ def _validate_module_name(module_name: str) -> str: return module_name -def _model_extension_mapping(module_name: str | None) -> dict[str, str]: +def _model_extension_mapping(module_name: str) -> dict[str, str]: """Load and validate a MODEL_EXTENSIONS mapping from an extension module.""" - module_name = _normalize_model_extension_module(module_name) - if not module_name: - return {} module_name = _validate_module_name(module_name) try: module = importlib.import_module(module_name) @@ -207,15 +244,91 @@ def _model_extension_mapping(module_name: str | None) -> dict[str, str]: 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 | None = DEFAULT_MODEL_EXTENSION_MODULE, + model_extension_module: str | Sequence[str] | None = None, ) -> str: """Generate Pydantic model classes and apply configured model extensions.""" schemas = schema.get("components", {}).get("schemas", {}) or {} - model_extension_module = _normalize_model_extension_module(model_extension_module) - extension_mapping = _model_extension_mapping(model_extension_module) + 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"""', "", @@ -236,13 +349,14 @@ def generate_models( and (schema_def.get("type") in {"object", None} or "properties" in schema_def) } used_extensions = { - class_name: extension_mapping[class_name] + class_name: extension_refs[class_name] for class_name in sorted(generated_class_names) - if class_name in extension_mapping + if class_name in extension_refs } - if model_extension_module and used_extensions: - imports = ", ".join(sorted(set(used_extensions.values()))) - lines.extend([f"from {model_extension_module} import {imports}", ""]) + 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])): @@ -252,9 +366,9 @@ def generate_models( lines.extend([f"{class_name} = Any", ""]) continue properties = schema_def.get("properties") or {} - bases = [used_extensions[class_name]] if class_name in used_extensions else [] - bases.append("TangleGeneratedModel") - lines.extend([f"class {class_name}({', '.join(bases)}):"]) + 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: @@ -265,12 +379,18 @@ def generate_models( else: lines.append(f" {field_name}: Any = None") lines.append("") + extension_bases = [ref.alias for ref in reversed(extension_refs_for_class)] + bases = extension_bases + [generated_base_name] + lines.extend([ + f"class {class_name}({', '.join(bases)}):", + " pass", + "", + ]) lines.append(f"__all__ = {exports!r}") lines.append("") return "\n".join(lines) - def _method_name(group_name: str, command_name: str) -> str: return f"{_safe_identifier(group_name)}_{_safe_identifier(command_name)}" @@ -501,7 +621,7 @@ def generate( generated_dir: str | Path = _GENERATED_DIR, *, operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, - model_extension_module: str | None = DEFAULT_MODEL_EXTENSION_MODULE, + model_extension_module: str | Sequence[str] | None = None, ) -> tuple[dict[str, Any], list[Path]]: schema = load_openapi_schema(openapi_path) output_dir = Path(generated_dir) @@ -568,12 +688,15 @@ def main(argv: list[str] | None = None) -> None: ) parser.add_argument( "--model-extension-module", - default=DEFAULT_MODEL_EXTENSION_MODULE, + action="append", + default=None, help=( "Importable module containing a MODEL_EXTENSIONS mapping from " - "generated model class names to extension class names; pass an " - "empty string to disable. " - f"(default: {DEFAULT_MODEL_EXTENSION_MODULE})." + "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( @@ -602,7 +725,7 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) try: _validate_class_name(args.operations_class_name) - _model_extension_mapping(args.model_extension_module) + _model_extension_refs(args.model_extension_module) 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)) diff --git a/tests/test_codegen.py b/tests/test_codegen.py index c58feea..c130df0 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import json from pathlib import Path @@ -108,7 +109,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][1]["backend_path"] == backend assert calls[1][0] == "generate" assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[1][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE + assert calls[1][1]["model_extension_module"] is None output = capsys.readouterr().out assert f"Loaded OpenAPI from backend: {backend}" in output assert f"Wrote {tmp_path / 'openapi.json'}" in output @@ -162,7 +163,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[0][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE + assert calls[0][1]["model_extension_module"] 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 @@ -197,7 +198,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiExtensions" - assert calls[0][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE + assert calls[0][1]["model_extension_module"] is None def test_codegen_main_accepts_empty_model_extension_module(monkeypatch, tmp_path) -> None: @@ -225,7 +226,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): ]) assert calls[0][0] == "generate" - assert calls[0][1]["model_extension_module"] == "" + assert calls[0][1]["model_extension_module"] == [""] def test_codegen_main_fetches_from_openapi_url_before_generating( @@ -271,7 +272,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): ) assert calls[1][0] == "generate" assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[1][1]["model_extension_module"] == codegen.DEFAULT_MODEL_EXTENSION_MODULE + assert calls[1][1]["model_extension_module"] is None output = capsys.readouterr().out assert "Loaded OpenAPI from URL: https://example.com/openapi.json" in output assert "Generated 1 operations from 1 paths" in output @@ -334,9 +335,10 @@ def test_generate_models_uses_builtin_model_extension_module_by_default() -> Non "from tangle_cli.generated_model_extensions import " "GetGraphExecutionStateResponseExtensions" ) in models + assert "class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel):" in models assert ( "class GetGraphExecutionStateResponse(" - "GetGraphExecutionStateResponseExtensions, TangleGeneratedModel):" + "GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated):" ) in models @@ -357,7 +359,8 @@ def test_generate_models_can_disable_builtin_model_extension_module() -> None: }, model_extension_module="") assert "generated_model_extensions" not in models - assert "class GetGraphExecutionStateResponse(TangleGeneratedModel):" 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: @@ -366,6 +369,9 @@ def test_generate_supports_model_extension_module(monkeypatch, tmp_path) -> None (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" @@ -405,8 +411,137 @@ def test_generate_supports_model_extension_module(monkeypatch, tmp_path) -> None models = (out / "models.py").read_text(encoding="utf-8") assert "from demo_extensions import FooResponseExtensions" in models - assert "class FooResponse(FooResponseExtensions, TangleGeneratedModel):" in models - assert "class OtherResponse(TangleGeneratedModel):" 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_codegen_main_rejects_invalid_model_extension_module(tmp_path, capsys) -> None: From 4768cbb03448e066d3f67f51a0d7d2ea9b6ff64a Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 20:32:50 -0700 Subject: [PATCH 022/111] test: prove consumer-local tangle api binding --- tests/test_packaging.py | 145 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 80fcb6c..3e67ed8 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,11 +1,14 @@ 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] @@ -21,8 +24,7 @@ def _build_wheel(tmp_path: Path, *args: str) -> Path: def _write_import_stubs(path: Path) -> None: path.mkdir() - (path / "httpx.py").write_text("", encoding="utf-8") - (path / "platformdirs.py").write_text("", encoding="utf-8") + _write_runtime_stubs(path) (path / "cyclopts.py").write_text( "class App:\n" " def __init__(self, *args, **kwargs): pass\n" @@ -39,6 +41,50 @@ def _write_import_stubs(path: Path) -> None: ) +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 / "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" @@ -74,6 +120,101 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: ) +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") From 528ef57bf58428d3d31aba0ba663caa866d0bba1 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 21:36:20 -0700 Subject: [PATCH 023/111] feat: add codegen model aliases and body overrides --- README.md | 24 +- .../tangle_cli/generated_model_extensions.py | 5 + .../src/tangle_cli/openapi/codegen.py | 306 +++++++++++++++-- tests/test_codegen.py | 307 ++++++++++++++++++ tests/test_models.py | 18 + 5 files changed, 636 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0bf3215..c1171e9 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ names to extension class names. The built-in `tangle_cli.generated_model_extensi module is applied first by default, and repeated `--model-extension-module` values are applied after it in order. Pass an empty string (`--model-extension-module ""`) to disable the default module. Generated object -models are emitted as private schema-derived bases plus public model classes, e.g.: +models are emitted as private schema-derived bases plus public model classes. Codegen also applies a built-in model alias so FastAPI schemas such as `ComponentSpecOutput` or `ComponentSpecInput` are additionally exposed as the stable public `ComponentSpec` class when `ComponentSpec` is absent from the schema. Add or override aliases with `--model-alias PublicModel=SourceSchema[,OtherSourceSchema]`; pass `--model-alias ""` to disable built-in aliases. For example: ```python MODEL_EXTENSIONS = { @@ -254,8 +254,23 @@ later/downstream extensions are leftmost in the public class MRO and override earlier/default extensions while schema-derived data remains available via `to_dict()`. Duplicate extension class names from different modules are imported with deterministic aliases. Extension classes must be importable from their -modules and should not import generated model classes. Codegen writes exactly -these support files: +modules and should not import generated model classes. + +Downstream generators can explicitly override a specific operation's JSON request-body schema without mutating the fetched OpenAPI document. This is useful when a backend schema is too specific or recursive for generated keyword arguments and the operation should accept an open-ended raw body. Use the OpenAPI `operationId`, generated method name, or `group.command` name: + +```bash +uv run python -m tangle_cli.openapi.codegen \ + --request-body-schema 'search_create={"type":"object","additionalProperties":true,"title":"SearchQuery"}' +``` + +For larger schemas, use a JSON file: + +```bash +uv run python -m tangle_cli.openapi.codegen \ + --request-body-schema-file search_create=search_query.json +``` + +These request-body overrides are generic and opt-in; OSS codegen has no built-in behavior for experimental downstream endpoints. Codegen writes exactly these support files: ```text /__init__.py @@ -293,7 +308,8 @@ uv run python -m tangle_cli.openapi.codegen \ --openapi-url https://oasis.shopify.io/openapi.json \ --out src/tangle_api/generated \ --operations-class-name GeneratedTangleApiExtensions \ - --model-extension-module tangle_deploy.tangle_api_model_extensions + --model-extension-module tangle_deploy.tangle_api_model_extensions \ + --request-body-schema 'search_create={"type":"object","additionalProperties":true,"title":"SearchQuery"}' ``` At the time of writing the official repository does not commit that raw diff --git a/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py b/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py index fa67481..63743e4 100644 --- a/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +++ b/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py @@ -125,6 +125,11 @@ def from_dict(cls, data: dict[str, Any]) -> Any: 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( diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index bc64977..270ce82 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import copy import importlib import json import keyword @@ -33,6 +34,14 @@ 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: @@ -55,15 +64,19 @@ def _class_name(name: str) -> str: return value -def _schema_ref_name(schema: dict[str, Any] | None) -> str | None: +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/"): - return _class_name(ref.rsplit("/", 1)[1]) + 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) + name = _schema_ref_name(child, model_ref_aliases=model_ref_aliases) if name: return name return None @@ -116,35 +129,44 @@ def _schema_allows_null(schema: dict[str, Any] | None) -> bool: return False -def _response_model_name(operation: dict[str, Any]) -> str | None: +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) - return _schema_ref_name(schema) + 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]) -> str: +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) + return _schema_return_annotation(schema, model_ref_aliases=model_ref_aliases) -def _schema_return_annotation(schema: dict[str, Any]) -> str: - ref_name = _schema_ref_name(schema) +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) + 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 @@ -165,6 +187,184 @@ def _schema_return_annotation(schema: dict[str, Any]) -> str: +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: @@ -324,10 +524,12 @@ def _model_extension_import_lines(refs_by_model: dict[str, list[_ModelExtensionR 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.""" - schemas = schema.get("components", {}).get("schemas", {}) or {} + 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"""', @@ -403,7 +605,12 @@ def _validate_class_name(name: str) -> str: return name -def _param_signature(parameters: list[Any], has_request_body: bool) -> tuple[str, list[str], list[str], list[str], bool]: +def _param_signature( + parameters: list[Any], + has_request_body: bool, + *, + raw_body_override: bool = False, +) -> tuple[str, list[str], list[str], list[str], bool]: required: list[Any] = [] optional: list[Any] = [] for parameter in parameters: @@ -431,7 +638,8 @@ def _param_signature(parameters: list[Any], has_request_body: bool) -> tuple[str body_names.append(name) include_body = has_request_body and not body_names if include_body: - signature_parts.append("body: Any = None") + 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, include_body @@ -444,12 +652,19 @@ def _dict_literal(names: list[str]) -> str: 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) - response_models = sorted({name for op in operations if (name := _response_model_name(op.operation))}) + _, 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"""', @@ -489,11 +704,13 @@ def generate_operations( raise RuntimeError(f"duplicate generated method {method_name}") used_methods.add(method_name) signature, path_names, query_names, body_names, include_body = _param_signature( - list(operation.parameters), operation.has_request_body + 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) + 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) + response_annotation = _response_return_annotation(operation.operation, model_ref_aliases) if signature: def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:" else: @@ -622,6 +839,8 @@ def generate( *, 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) @@ -636,11 +855,20 @@ def generate( encoding="utf-8", ) generated_files[1].write_text( - generate_models(schema, model_extension_module=model_extension_module), + 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), + 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 @@ -699,6 +927,37 @@ def main(argv: list[str] | None = None) -> None: 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, @@ -726,6 +985,11 @@ def main(argv: list[str] | None = None) -> None: 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)) @@ -761,6 +1025,8 @@ def main(argv: list[str] | None = None) -> None: 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, diff --git a/tests/test_codegen.py b/tests/test_codegen.py index c130df0..2064fa8 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -110,6 +110,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[1][0] == "generate" 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 {tmp_path / 'openapi.json'}" in output @@ -164,6 +165,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiOperations" assert calls[0][1]["model_extension_module"] is None + assert calls[0][1]["model_aliases"] is None output = capsys.readouterr().out assert f"Loaded OpenAPI from snapshot: {tmp_path / 'openapi.json'}" in output assert f"Wrote {tmp_path / 'openapi.json'}" not in output @@ -199,6 +201,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiExtensions" assert calls[0][1]["model_extension_module"] is None + assert calls[0][1]["model_aliases"] is None def test_codegen_main_accepts_empty_model_extension_module(monkeypatch, tmp_path) -> None: @@ -227,6 +230,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["model_extension_module"] == [""] + assert calls[0][1]["model_aliases"] is None def test_codegen_main_fetches_from_openapi_url_before_generating( @@ -273,6 +277,7 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[1][0] == "generate" 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 "Generated 1 operations from 1 paths" in output @@ -314,6 +319,157 @@ def test_generate_writes_support_modules_to_custom_out(tmp_path) -> None: 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", @@ -544,6 +700,157 @@ def test_generate_deduplicates_colliding_extension_aliases(monkeypatch, tmp_path 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_keeps_body_kwargs(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"}}, + } + } + } + }, + } + } + }, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate(openapi, out) + + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "def search_create(self, query: Any = None)" in operations + assert "json_data={'query': query}" in operations + assert "body: dict[str, Any] | None" not in operations + + +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([ diff --git a/tests/test_models.py b/tests/test_models.py index 654de05..6408514 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -95,6 +95,24 @@ def test_from_dict_parses_text_when_spec_missing(self): 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", From 7b37f230ac680dd5784f2dbe61d35fab06fd54dc Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 11 Jun 2026 22:04:20 -0700 Subject: [PATCH 024/111] feat: add artifact response factory --- packages/tangle-cli/src/tangle_cli/models.py | 72 ++++++++--------- packages/tangle-cli/src/tangle_cli/utils.py | 55 ++++++++++++- tests/test_models.py | 83 +++++++++++++++++++- tests/test_packaging.py | 13 +++ tests/test_tangle_deploy_compat_imports.py | 4 +- 5 files changed, 187 insertions(+), 40 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py index 6133bd1..f5428b0 100644 --- a/packages/tangle-cli/src/tangle_cli/models.py +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -9,41 +9,19 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse +from .utils import ( + _optional_str, + _strip_text_from_graph, + _value_from_mapping_or_object, + add_official_prefix, +) -# ---- Helpers --------------------------------------------------------------- - - -def _strip_text_from_graph(implementation: dict[str, Any]) -> None: - """Recursively remove ``text`` from ``componentRef`` objects in a graph implementation.""" - 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): - """ - Add the [Official] prefix to a component name if not already present. - - Args: - name: The original component name - - Returns: - The name with [Official] prefix - """ - if name and not name.startswith("[Official]"): - return f"[Official] {name}" - return name +if TYPE_CHECKING: + from tangle_api.generated.models import GetArtifactInfoResponse # ---- Execution / Run dataclasses ------------------------------------------- @@ -322,12 +300,34 @@ def from_dict(cls, data: dict[str, Any], key: str = "") -> ArtifactInfo: ad = data.get("artifact_data", {}) return cls( id=data.get("id", ""), - uri=ad.get("uri", ""), + uri=_value_from_mapping_or_object(ad, "uri", ""), + key=key, + total_size=_value_from_mapping_or_object(ad, "total_size", 0), + is_dir=_value_from_mapping_or_object(ad, "is_dir", False), + hash=_optional_str(_value_from_mapping_or_object(ad, "hash")), + created_at=_optional_str(_value_from_mapping_or_object(ad, "created_at")), + ) + + @classmethod + def from_response( + cls, + response: GetArtifactInfoResponse, + *, + key: str = "", + ) -> ArtifactInfo: + """Create a flattened artifact DTO from a generated artifact response.""" + + artifact_data = getattr(response, "artifact_data", None) + total_size = _value_from_mapping_or_object(artifact_data, "total_size", 0) + is_dir = _value_from_mapping_or_object(artifact_data, "is_dir", False) + return cls( + id=str(getattr(response, "id", "") or ""), + uri=str(_value_from_mapping_or_object(artifact_data, "uri", "") or ""), key=key, - total_size=ad.get("total_size", 0), - is_dir=ad.get("is_dir", False), - hash=ad.get("hash"), - created_at=ad.get("created_at"), + 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(_value_from_mapping_or_object(artifact_data, "hash")), + created_at=_optional_str(_value_from_mapping_or_object(artifact_data, "created_at")), ) diff --git a/packages/tangle-cli/src/tangle_cli/utils.py b/packages/tangle-cli/src/tangle_cli/utils.py index 30e9abf..0449d45 100644 --- a/packages/tangle-cli/src/tangle_cli/utils.py +++ b/packages/tangle-cli/src/tangle_cli/utils.py @@ -10,7 +10,7 @@ import re import subprocess from collections import OrderedDict -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from typing import Any @@ -18,6 +18,59 @@ 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 # ============================================================================= diff --git a/tests/test_models.py b/tests/test_models.py index 6408514..9dc189e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,10 +3,13 @@ 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, @@ -14,8 +17,8 @@ PipelineRun, SecretInfo, UserInfo, - add_official_prefix, ) +from tangle_cli.utils import add_official_prefix class TestPipelineRun: @@ -185,6 +188,84 @@ def test_falls_back_to_kubernetes_job_name(self): 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"]) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 3e67ed8..3a371a1 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -45,6 +45,19 @@ 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" diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index a7ec8e3..fe3fb46 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -44,8 +44,6 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: SecretInfo, TaskSpec, UserInfo, - _strip_text_from_graph, - add_official_prefix, ) from tangle_cli.utils import ( _CI_BRANCH_VARS, @@ -54,6 +52,8 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: _CI_SHA_VARS, OrderedDict, TaskProcessor, + _strip_text_from_graph, + add_official_prefix, UnsetVarError, _extract_recursive_params, _extract_source_dir, From 0fc8a7981274ae3fe084b06a7760aed570997a58 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 05:43:57 -0700 Subject: [PATCH 025/111] fix: harden API request handling --- .../src/tangle_cli/api_transport.py | 11 +- packages/tangle-cli/src/tangle_cli/client.py | 197 ++++++++++++++++-- .../src/tangle_cli/openapi/codegen.py | 10 + tests/test_api_transport.py | 50 +++++ tests/test_codegen.py | 10 + tests/test_static_client.py | 146 +++++++++++++ 6 files changed, 400 insertions(+), 24 deletions(-) create mode 100644 tests/test_api_transport.py diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index 5d9e0f6..3ed4169 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -232,7 +232,7 @@ def build_operation_request( names = ", ".join(sorted(remaining)) raise TypeError(f"Unexpected parameter(s) for {operation.group_name}.{operation.command_name}: {names}") - url = urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + url = _join_operation_url(base_url, path) if query: url = f"{url}?{_urlencode_query(query)}" @@ -261,6 +261,15 @@ def build_operation_request( 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.""" diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index a476b6c..b75bce3 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -8,15 +8,18 @@ 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 +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, @@ -42,6 +45,12 @@ class TangleApiClient(GeneratedTangleApiOperations): 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, @@ -97,38 +106,165 @@ def _make_request( json_data = kwargs.pop("json") timeout = kwargs.pop("timeout", self.timeout) extra_headers = kwargs.pop("headers", None) - request_headers = self._headers(extra_headers) url = self._url(path) clean_params = self._clean_mapping(params) + request_method = method.upper() self._refresh_auth() - # Refresh may mutate headers/session state, so build headers again. - request_headers = self._headers(extra_headers) - if self.verbose: - self.logger.info(f"{method.upper()} {url}") - response = self.session.request( - method.upper(), + response = self._request_with_rate_limit_retries( + request_method, url, params=clean_params, - json=json_data, - headers=request_headers, + json_data=json_data, + extra_headers=extra_headers, timeout=timeout, - **kwargs, + request_kwargs=kwargs, ) if response.status_code == 401: self._refresh_auth() - request_headers = self._headers(extra_headers) - response = self.session.request( - method.upper(), + response = self._request_with_rate_limit_retries( + request_method, url, params=clean_params, - json=json_data, - headers=request_headers, + 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, - **kwargs, + 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) + if self.verbose: + self.logger.info(f"{current_method} {current_url}") + 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 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, @@ -164,9 +300,7 @@ def _headers(self, extra_headers: Mapping[str, str] | None = None) -> dict[str, ) def _url(self, path: str) -> str: - if path.startswith("http://") or path.startswith("https://"): - return path - return urljoin(self.base_url.rstrip("/") + "/", path.lstrip("/")) + return _join_operation_url(self.base_url, path) @staticmethod def _format_path(path: str, path_params: Mapping[str, Any] | None = None) -> str: @@ -363,13 +497,30 @@ def get_run_details( include_execution_state: bool = False, execution_id: str | None = None, ) -> RunDetails: - run = PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) - root_execution_id = execution_id or run.root_execution_id + 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(run_id) if include_annotations else None + 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( diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index 270ce82..f8d787f 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -20,6 +20,7 @@ import re import sys import tempfile +import urllib.parse import urllib.request from collections import Counter from collections.abc import Sequence @@ -649,6 +650,14 @@ def _dict_literal(names: list[str]) -> str: return "{" + ", ".join(f"{name!r}: {name}" for name in names) + "}" +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, @@ -699,6 +708,7 @@ def generate_operations( 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}") diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py new file mode 100644 index 0000000..83b9c9f --- /dev/null +++ b/tests/test_api_transport.py @@ -0,0 +1,50 @@ +from types import SimpleNamespace + +import pytest + +from tangle_cli.api_transport import build_operation_request + + +def _operation(path: str) -> SimpleNamespace: + return SimpleNamespace( + method="GET", + path=path, + parameters=[], + group_name="test", + command_name="op", + has_request_body=False, + ) + + +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_codegen.py b/tests/test_codegen.py index 2064fa8..2513c19 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -21,6 +21,16 @@ def _generated_files(tmp_path: Path) -> list[Path]: ] +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_update_from_openapi_url_writes_snapshot(tmp_path) -> None: source = tmp_path / "official-openapi.json" destination = tmp_path / "openapi.json" diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 9763a2c..6c00d76 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -3,6 +3,7 @@ import json from typing import Any +import pytest import requests from tangle_cli.client import TangleApiClient @@ -86,6 +87,104 @@ def test_request_json_instantiates_list_response_models() -> None: 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", @@ -168,6 +267,53 @@ def test_get_run_details_uses_native_operations_for_retained_semantic_helper() - ] +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", From 46c48fd59b5e04514f2d5770d065b4678329f2bc Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 06:29:34 -0700 Subject: [PATCH 026/111] refactor: move OpenAPI snapshot to API package --- README.md | 23 ++-- .../src/tangle_api/schema/__init__.py | 0 .../src/tangle_api/schema}/openapi.json | 0 .../src/tangle_cli/openapi/codegen.py | 40 ++++-- .../src/tangle_cli/openapi/parser.py | 48 ++++++-- tests/test_codegen.py | 116 ++++++++++++++++-- tests/test_packaging.py | 8 +- 7 files changed, 199 insertions(+), 36 deletions(-) create mode 100644 packages/tangle-api/src/tangle_api/schema/__init__.py rename packages/{tangle-cli/src/tangle_cli/openapi => tangle-api/src/tangle_api/schema}/openapi.json (100%) diff --git a/README.md b/README.md index c1171e9..bbd9527 100644 --- a/README.md +++ b/README.md @@ -183,9 +183,10 @@ existing = client.find_existing_components( ) ``` -`TangleApiClient` uses checked-in endpoint methods generated offline from -`tangle_cli/openapi/openapi.json` into the native `tangle_api.generated` package, -so normal imports do not fetch or parse the OpenAPI schema. Handwritten semantic +`TangleApiClient` uses checked-in endpoint methods generated offline from the +native `tangle_api.schema` OpenAPI snapshot into the native +`tangle_api.generated` package, so normal imports do not fetch or parse the +OpenAPI schema. Handwritten semantic helpers such as `find_existing_components(...)` return domain models; that helper accepts component specs, mapping references, or plain names plus optional names/digests @@ -200,9 +201,10 @@ package before importing `tangle_cli.client`. The repository is split into two import packages: `tangle_cli` contains the CLI, business helpers, dynamic discovery, codegen, runtime base classes, and default model extensions; `tangle_api` contains only the native checked-in generated -models and operation proxies for the official OSS API. Downstream consumers that -vendor `tangle_cli` can generate their own local `tangle_api.generated` package -from their schema without vendoring cli-lab's native generated package. +models, operation proxies, and official OpenAPI snapshot for the official OSS +API. Downstream consumers that vendor `tangle_cli` can generate their own local +`tangle_api.generated` package from their schema without vendoring cli-lab's +native generated package or official snapshot. To refresh the checked-in generated methods/models from the official Tangle backend submodule, run: @@ -215,8 +217,9 @@ uv run pytest ``` With no source flags, codegen loads OpenAPI from the default official backend -submodule at `third_party/tangle`, writes `tangle_cli/openapi/openapi.json`, and -regenerates `packages/tangle-api/src/tangle_api/generated`. The backend import creates a database engine +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. If the submodule is missing, initialize it with `git submodule update --init --recursive`. @@ -282,8 +285,8 @@ The generated models import shared runtime helpers from `tangle_cli.generated_ru The public client remains handwritten at `tangle_cli/client.py`; codegen does not create a default generated public client wrapper. -To regenerate from the already checked-in snapshot instead of the backend, pass -`--from-snapshot` explicitly: +To regenerate from the already checked-in API-package snapshot instead of the +backend, pass `--from-snapshot` explicitly: ```bash uv run python -m tangle_cli.openapi.codegen --from-snapshot 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-cli/src/tangle_cli/openapi/openapi.json b/packages/tangle-api/src/tangle_api/schema/openapi.json similarity index 100% rename from packages/tangle-cli/src/tangle_cli/openapi/openapi.json rename to packages/tangle-api/src/tangle_api/schema/openapi.json diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index f8d787f..485e658 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -28,7 +28,13 @@ from pathlib import Path from typing import Any -from .parser import DEFAULT_OPENAPI_PATH, load_openapi_schema, parsed_operations +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" @@ -844,7 +850,7 @@ def write_openapi_schema(schema: dict[str, Any], destination: str | Path = DEFAU def generate( - openapi_path: str | Path = DEFAULT_OPENAPI_PATH, + openapi_path: str | Path | None = None, generated_dir: str | Path = _GENERATED_DIR, *, operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, @@ -884,6 +890,12 @@ def generate( 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: @@ -910,7 +922,15 @@ def _print_summary( def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--openapi", default=str(DEFAULT_OPENAPI_PATH), help="Path to openapi.json") + 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), @@ -989,7 +1009,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument( "--from-snapshot", action="store_true", - help="Regenerate support modules from the existing local openapi.json snapshot.", + help="Regenerate support modules from the official API-package openapi.json snapshot.", ) args = parser.parse_args(argv) try: @@ -1006,13 +1026,15 @@ def main(argv: list[str] | None = None) -> None: 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=args.openapi) + update_openapi_from_url(args.openapi_url, destination=openapi_path) source = f"URL: {args.openapi_url}" wrote_openapi = True elif args.from_snapshot: - source = f"snapshot: {_display_path(args.openapi)}" + 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(): @@ -1024,14 +1046,14 @@ def main(argv: list[str] | None = None) -> None: ) update_openapi_from_backend( backend_path=backend_path, - destination=args.openapi, + destination=openapi_path, database_uri=args.backend_database_uri, ) source = f"backend: {_display_path(backend_path)}" wrote_openapi = True schema, generated_files = generate( - args.openapi, + openapi_path, args.out, operations_class_name=args.operations_class_name, model_extension_module=args.model_extension_module, @@ -1040,7 +1062,7 @@ def main(argv: list[str] | None = None) -> None: ) _print_summary( source=source, - openapi_path=args.openapi, + openapi_path=openapi_path or DEFAULT_OPENAPI_PATH, generated_files=generated_files, schema=schema, wrote_openapi=wrote_openapi, diff --git a/packages/tangle-cli/src/tangle_cli/openapi/parser.py b/packages/tangle-cli/src/tangle_cli/openapi/parser.py index 3633dff..2376413 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/parser.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/parser.py @@ -2,25 +2,25 @@ 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 ``openapi.json`` snapshot. +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 -PACKAGE_DIR = Path(__file__).resolve().parent -DEFAULT_OPENAPI_PATH = PACKAGE_DIR / "openapi.json" +_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" -def load_openapi_schema(path: str | Path | None = None) -> dict[str, Any]: - """Load a Tangle OpenAPI schema from disk.""" - - schema_path = Path(path) if path is not None else DEFAULT_OPENAPI_PATH +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: @@ -28,6 +28,40 @@ def load_openapi_schema(path: str | Path | None = None) -> dict[str, Any]: return schema +def _load_default_openapi_schema() -> dict[str, Any]: + if DEFAULT_OPENAPI_PATH.exists(): + return _load_json_file(DEFAULT_OPENAPI_PATH) + + try: + schema_text = ( + resources.files(DEFAULT_OPENAPI_RESOURCE_PACKAGE) + .joinpath(DEFAULT_OPENAPI_RESOURCE_NAME) + .read_text(encoding="utf-8") + ) + except ModuleNotFoundError as exc: + 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 exc + + schema = json.loads(schema_text) + if not isinstance(schema, dict) or "paths" not in schema: + raise ValueError( + f"{DEFAULT_OPENAPI_RESOURCE_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.""" diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 2513c19..63753f9 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -6,7 +6,7 @@ import pytest -from tangle_cli.openapi import codegen +from tangle_cli.openapi import codegen, parser def _schema(paths: dict | None = None) -> dict: @@ -31,6 +31,23 @@ def test_generate_operations_rejects_network_path_references() -> None: codegen.generate_operations(_schema({"//attacker.example/collect": {"get": {}}})) +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" @@ -83,7 +100,9 @@ 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]] = [] @@ -105,25 +124,26 @@ def fake_generate(openapi_path, 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([ - "--openapi", - str(tmp_path / "openapi.json"), "--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 {tmp_path / 'openapi.json'}" 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 @@ -182,6 +202,35 @@ def fake_generate(openapi_path, generated_dir, **kwargs): 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]] = [] @@ -243,14 +292,16 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][1]["model_aliases"] is None -def test_codegen_main_fetches_from_openapi_url_before_generating( +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 = tmp_path / "openapi.json" + openapi_path = Path(kwargs["destination"]) + openapi_path.parent.mkdir(parents=True) openapi_path.write_text(json.dumps(_schema()), encoding="utf-8") return openapi_path @@ -265,12 +316,11 @@ def fake_generate(openapi_path, 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([ - "--openapi", - str(tmp_path / "openapi.json"), "--out", str(tmp_path / "generated"), "--openapi-url", @@ -281,18 +331,66 @@ def fake_generate(openapi_path, generated_dir, **kwargs): "update-url", { "openapi_url": "https://example.com/openapi.json", - "destination": str(tmp_path / "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" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 3a371a1..9141e7d 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -109,6 +109,7 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) + assert "tangle_cli/openapi/openapi.json" not in names assert "Requires-Dist: tangle-api==0.0.1" not in requires_dist assert "Requires-Dist: tangle-api==0.0.1 ; extra == 'native'" in requires_dist @@ -231,6 +232,9 @@ def test_codegen_output_imports_as_consumer_local_tangle_api(tmp_path) -> None: 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( @@ -238,7 +242,9 @@ def test_native_wheels_provide_static_client_binding(tmp_path) -> None: sys.executable, "-c", "from tangle_cli.client import TangleApiClient; " - "assert TangleApiClient.__name__ == '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, From d537afe570346bdbd229d626e9afb8b214a5a42d Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 06:47:00 -0700 Subject: [PATCH 027/111] fix: avoid native schema for API cache commands --- README.md | 10 ++- packages/tangle-cli/src/tangle_cli/api_cli.py | 33 ++++++-- tests/test_api_cli.py | 80 +++++++++++++++++++ tests/test_packaging.py | 26 ++++++ 4 files changed, 138 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bbd9527..aa46431 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ uv run tangle sdk published-components library ## API commands -API commands are pre-generated from the checked-in official Tangle FastAPI/OpenAPI snapshot, so `tangle api --help` shows resource command groups immediately on a cold cache and command invocations do not require `refresh` first. By default, the CLI uses `--schema-source auto`: official static operations are always present, and cached live-backend operations discovered by `tangle api refresh` are included as extensions when they exist. Cached schemas do not override official operations with the same method/path; official definitions win. +API commands are pre-generated from the checked-in official Tangle FastAPI/OpenAPI snapshot, so native installs (`tangle-cli[native]`, or development installs with the workspace `tangle-api` package) show resource command groups immediately on a cold cache and command invocations do not require `refresh` first. By default, the CLI uses `--schema-source auto`: official static operations are always present when the native API package is installed, and cached live-backend operations discovered by `tangle api refresh` are included as extensions when they exist. Cached schemas do not override official operations with the same method/path; official definitions win. + +Default `tangle-cli` installs without the native `tangle-api` package can still run cache-management commands such as `tangle api refresh` and can dispatch cached operations with `--schema-source cache`. Official static API commands require `tangle-cli[native]` so the packaged `tangle_api.schema` snapshot is available. You can refresh the local schema cache explicitly for a live backend with: @@ -116,9 +118,9 @@ Repeated `--header 'Name: value'` flags can be used with both `tangle api refres Schema source modes are: -- `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. -- `--schema-source official`: only the checked-in official static schema (OSS-only/core commands). -- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. +- `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Requires `tangle-cli[native]` for official operations. +- `--schema-source official`: only the checked-in official static schema (OSS-only/core commands). Requires `tangle-cli[native]`. +- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. Does not require the native API package. For resource help, put the option on the resource group: diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index 08d020f..a4dade6 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -416,11 +416,11 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: if api_tail is None: return None - schema_source = _schema_source_from_argv(api_tail) - official = load_bundled_openapi_schema() - if schema_source == "official": - return official + first_command = _api_first_command(api_tail) + if first_command in {"refresh", "reset-cache"}: + return None + schema_source = _schema_source_from_argv(api_tail) base_url = _base_url_from_argv(api_tail) or default_base_url() cached = load_cached_schema(base_url) if schema_source == "cache": @@ -428,14 +428,33 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: raise SystemExit( f"No cached OpenAPI schema for {_normalize_base_url(base_url)}. " "Run `tangle api refresh` with the same --base-url/--auth-header/--header options, " - "or use `--schema-source official` to use the official static schema." + "or install tangle-cli[native] to use the official static schema." ) return cached + + try: + official = load_bundled_openapi_schema() + except FileNotFoundError as exc: + if first_command is None: + return None + 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 _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], @@ -482,7 +501,7 @@ def _argv_requests_api_schema(argv: list[str]) -> bool: if api_tail is None: return False first_command = _api_first_command(api_tail) - return first_command not in {None, "refresh"} + return first_command not in {None, "refresh", "reset-cache"} def _argv_dispatches_dynamic_command(argv: list[str]) -> bool: @@ -490,7 +509,7 @@ def _argv_dispatches_dynamic_command(argv: list[str]) -> bool: if api_tail is None: return False first_command = _api_first_command(api_tail) - return first_command not in {None, "refresh"} + return first_command not in {None, "refresh", "reset-cache"} def _api_argv_tail(argv: list[str]) -> list[str] | None: diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index e3dfe55..0cb9770 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -451,6 +451,86 @@ def fake_get(url, **kwargs): 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 + + +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( + _oasis_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 ): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 9141e7d..c0a23c4 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -134,6 +134,32 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: ) +def test_tangle_cli_wheel_api_refresh_builds_without_native_tangle_api(tmp_path) -> None: + 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") From 4d3fea9105959478662721aab1c7c4a96473cbef Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 06:59:57 -0700 Subject: [PATCH 028/111] feat: add CLI config file support --- README.md | 31 ++ packages/tangle-cli/src/tangle_cli/api_cli.py | 192 ++++++++++--- .../src/tangle_cli/args_container.py | 244 ++++++++++++++++ .../tangle_cli/published_components_cli.py | 170 +++++++---- tests/test_api_cli.py | 270 ++++++++++++++++++ tests/test_args_container.py | 110 +++++++ tests/test_tangle_deploy_compat_imports.py | 2 + 7 files changed, 933 insertions(+), 86 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/args_container.py create mode 100644 tests/test_args_container.py diff --git a/README.md b/README.md index aa46431..bcc70de 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,37 @@ Omit `--schema-source` to use `auto`, which includes cached-only backend extensions after refresh while preserving official definitions for core operations. Use `--schema-source official` to force OSS-only/core commands. +### Config files + +Implemented API commands and `tangle sdk published-components` commands accept +`--config path/to/config.yaml` (or JSON) for command defaults. Explicit CLI +arguments take precedence over config-file values. 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 + header: + - "Cloud-Auth: ..." + +configs: + - schema_source: cache + filter: active + limit: 10 + - schema_source: cache + 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 +``` + +For generated `tangle api` commands, config keys use the generated CLI +parameter names such as `base_url`, `schema_source`, `body`, and endpoint +parameters like `limit`, `filter`, or `id`. + ## Static and dynamic command examples OpenAPI resource paths are available as command groups from the checked-in official schema, with cached-only backend extensions included in auto mode after refresh. For example, `/api/pipeline_runs/` becomes `pipeline-runs`, `/api/components/{digest}` becomes `components`, and `/api/published_components/` becomes `published-components`: diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index a4dade6..a3603ab 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -22,6 +22,7 @@ import platformdirs from cyclopts import App, Parameter +from .args_container import ArgsContainer, ConfigFileError from .api_schema import ( SUPPORTED_METHODS, CliParameter, @@ -120,6 +121,32 @@ ) ), ] +ConfigOption = Annotated[ + str | None, + Parameter(help="YAML/JSON config file providing command defaults."), +] + + +def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: + try: + return ArgsContainer.load(config, **kwargs) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + + +def _common_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, ...]]: + return { + "base_url": (base_url, None), + "token": (token, None), + "auth_header": (auth_header, None), + "header": (header, None), + } def build_app(schema: dict[str, Any] | None = None) -> App: @@ -182,41 +209,61 @@ def refresh( token: TokenOption = None, auth_header: AuthHeaderOption = None, header: HeaderOption = None, + config: ConfigOption = None, ) -> None: """Fetch /openapi.json and update the local schema cache.""" - normalized_base_url = _normalize_base_url(base_url) if base_url else default_base_url() - try: - schema, path = refresh_schema(normalized_base_url, token, header, auth_header) - 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}") + for args in _load_args( + config, + **_common_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + 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, + ) + 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) -> None: + def reset_cache(*, base_url: BaseUrlOption = None, config: ConfigOption = None) -> None: """Delete the cached live OpenAPI schema for a base URL.""" - normalized_base_url = _normalize_base_url(base_url) if 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}") + for args in _load_args(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): @@ -262,7 +309,8 @@ def _operation_signature(operation: OperationCommand) -> inspect.Signature: inspect.Parameter( parameter.local_name, inspect.Parameter.POSITIONAL_OR_KEYWORD, - annotation=annotation, + default=None, + annotation=_optional_type(annotation), ) ) @@ -276,11 +324,7 @@ def _operation_signature(operation: OperationCommand) -> inspect.Signature: else parameter.python_type, parameter.description, ) - default = ( - inspect.Parameter.empty - if parameter.required and not body_field_with_escape_hatch - else parameter.default - ) + default = parameter.default if not parameter.required else None parameters.append( inspect.Parameter( parameter.local_name, @@ -300,6 +344,14 @@ def _operation_signature(operation: OperationCommand) -> inspect.Signature: ) ) + parameters.append( + inspect.Parameter( + "config", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=ConfigOption, + ) + ) parameters.append( inspect.Parameter( "schema_source", @@ -364,6 +416,52 @@ def _operation_help(operation: OperationCommand) -> str: 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) + for args in _operation_args_from_config(operation, values, config): + _invoke_operation_once(operation, args.to_dict()) + + +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( + _common_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(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]) -> 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) or default_token() @@ -539,6 +637,7 @@ def _api_first_command(api_tail: list[str]) -> str | None: "--header", "-H", "--schema-source", + "--config", } for arg in api_tail: if skip_next: @@ -556,8 +655,10 @@ def _api_first_command(api_tail: list[str]) -> str | None: def _schema_source_from_argv(argv: list[str]) -> str: - value = _option_from_argv(argv, "--schema-source") or "auto" - return _validate_schema_source(value) + 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: @@ -583,7 +684,11 @@ def _schema_fetch_failure_message(base_url: str, exc: Exception) -> str: def _base_url_from_argv(argv: list[str]) -> str | None: - return _option_from_argv(argv, "--base-url") or _option_from_argv(argv, "--api-url") + return ( + _option_from_argv(argv, "--base-url") + or _option_from_argv(argv, "--api-url") + or _optional_str(_config_value_from_argv(argv, "base_url")) + ) def _token_from_argv(argv: list[str]) -> str | None: @@ -594,6 +699,23 @@ def _auth_header_from_argv(argv: list[str]) -> str | None: return _option_from_argv(argv, "--auth-header") or default_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): 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/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 7e08bf8..21f1c60 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -7,6 +7,7 @@ from cyclopts import App, Parameter +from .args_container import ArgsContainer, ConfigFileError from .api_transport import DEFAULT_TIMEOUT_SECONDS BaseUrlOption = Annotated[ @@ -34,6 +35,10 @@ negative_iterable=(), ), ] +ConfigOption = Annotated[ + str | None, + Parameter(help="YAML/JSON config file providing command defaults."), +] app = App( name="published-components", @@ -74,6 +79,28 @@ def _print_json(payload: object) -> None: print(json.dumps(payload, indent=2, sort_keys=True)) +def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: + try: + return ArgsContainer.load(config, **kwargs) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + + +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, ...]]: + return { + "base_url": (base_url, None), + "token": (token, None), + "auth_header": (auth_header, None), + "header": (header, None), + } + + def search_components(*args: Any, **kwargs: Any) -> Any: from .component_inspector import search_components as _search_components @@ -102,31 +129,45 @@ def get_standard_library(*args: Any, **kwargs: Any) -> Any: def published_components_search( name: str | None = None, *, - include_deprecated: bool = False, + 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, ) -> None: """Search published component metadata.""" - client = _client_from_options( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - ) - _print_json( - search_components( - client, - name=name, - include_deprecated=include_deprecated, - published_by=published_by, - digest=digest, + for args in _load_args( + config, + name=(name, None), + include_deprecated=(include_deprecated, None), + published_by=(published_by, None), + digest=(digest, None), + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + ) + _print_json( + search_components( + client, + name=args.name, + include_deprecated=bool(args.include_deprecated), + published_by=args.published_by, + digest=args.digest, + ) ) - ) @app.command(name="inspect") @@ -134,44 +175,61 @@ def published_components_inspect( name: str | None = None, *, digest: str | None = None, - all_versions: bool = False, - include_deprecated: bool = False, - follow_deprecated: bool = False, - full_spec: bool = False, + 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, ) -> None: """Inspect a published component by exact name or digest.""" - if bool(name) == bool(digest): - raise SystemExit("Provide exactly one of NAME or --digest DIGEST") - - client = _client_from_options( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - ) - if digest: - result = inspect_by_digest( - client, - digest, - full_spec=full_spec, - follow_deprecated=follow_deprecated, - ) - else: - result = inspect_by_name( - client, - name or "", - include_all_versions=all_versions, - include_deprecated=include_deprecated, - full_spec=full_spec, - published_by=published_by, + for args in _load_args( + 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), + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + 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, ) - _print_json(result) + if args.digest: + result = inspect_by_digest( + client, + args.digest, + full_spec=bool(args.full_spec), + follow_deprecated=bool(args.follow_deprecated), + ) + else: + result = inspect_by_name( + client, + 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) @app.command(name="library") @@ -181,13 +239,23 @@ def published_components_library( token: TokenOption = None, auth_header: AuthHeaderOption = None, header: HeaderOption = None, + config: ConfigOption = None, ) -> None: """Print the curated standard component library.""" - client = _client_from_options( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - ) - _print_json(get_standard_library(client)) + for args in _load_args( + config, + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + ) + _print_json(get_standard_library(client)) diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 0cb9770..ca7aa5e 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -396,6 +396,111 @@ def fake_client_from_options(**kwargs): 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" + "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) + monkeypatch.setattr( + published_components_cli, + "search_components", + lambda client, **kwargs: {"client_ok": 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" + assert client_calls[-1]["header"] == ["X-Config: yes"] + + +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) + monkeypatch.setattr( + published_components_cli, + "inspect_by_digest", + lambda client, digest, **kwargs: { + "client_ok": client is fake_client, + "digest": digest, + "inspect": kwargs, + }, + ) + monkeypatch.setattr( + published_components_cli, + "get_standard_library", + lambda client: {"client_ok": 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" + + 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" + + def test_sdk_published_components_client_from_options_uses_static_client(): from tangle_cli.client import TangleApiClient @@ -813,6 +918,100 @@ def test_reset_cache_reports_noop_when_cache_absent(monkeypatch, tmp_path, capsy 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): + calls.append({ + "base_url": base_url, + "token": token, + "header": header, + "auth_header": auth_header, + }) + 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", + }] + assert "Cached OpenAPI schema for https://cli.example" in capsys.readouterr().out + + +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( + _oasis_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") @@ -944,6 +1143,77 @@ def fake_request(method, url, **kwargs): 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_dynamic_command_invocation_maps_path_query_and_body(monkeypatch, capsys): requests = [] 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_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index fe3fb46..f81d960 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -10,6 +10,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: 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_inspector import ( get_standard_library, inspect_by_digest, @@ -93,6 +94,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert callable(StaticClient("https://api.test").set_verbose) assert ComponentSpec.__name__ == "ComponentSpec" assert PipelineRun.__name__ == "PipelineRun" + assert ArgsContainer and ConfigFileError assert callable(get_standard_library) assert callable(inspect_by_digest) assert callable(inspect_by_name) From b516db0e335a9d6bcd792c753e2efe1c678d9cdf Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 07:26:22 -0700 Subject: [PATCH 029/111] fix: guard implicit localhost auth --- .../src/tangle_cli/api_transport.py | 22 ++++++- tests/test_api_transport.py | 62 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index 3ed4169..841fedc 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -18,7 +18,27 @@ def default_base_url() -> str: - return _normalize_base_url(os.environ.get("TANGLE_API_URL") or DEFAULT_API_URL) + 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: diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index 83b9c9f..28b67ce 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -2,7 +2,7 @@ import pytest -from tangle_cli.api_transport import build_operation_request +from tangle_cli.api_transport import build_operation_request, default_base_url def _operation(path: str) -> SimpleNamespace: @@ -16,6 +16,66 @@ def _operation(path: str) -> SimpleNamespace: ) +@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" + + def test_build_operation_request_rejects_absolute_url_paths() -> None: with pytest.raises(ValueError, match="must be relative"): build_operation_request( From 09061de44b1afccab40cee1ac6336f679a0209e2 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 07:36:06 -0700 Subject: [PATCH 030/111] fix: avoid implicit localhost for API help --- packages/tangle-cli/src/tangle_cli/api_cli.py | 8 +++-- tests/test_api_cli.py | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index a3603ab..bfc344c 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -14,6 +14,7 @@ import inspect import json +import os import re import sys from typing import Annotated, Any @@ -519,9 +520,11 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: return None schema_source = _schema_source_from_argv(api_tail) - base_url = _base_url_from_argv(api_tail) or default_base_url() - cached = load_cached_schema(base_url) + explicit_base_url = _base_url_from_argv(api_tail) + configured_base_url = explicit_base_url or os.environ.get("TANGLE_API_URL") 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)}. " @@ -530,6 +533,7 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: ) return cached + cached = load_cached_schema(configured_base_url) if configured_base_url else None try: official = load_bundled_openapi_schema() except FileNotFoundError as exc: diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index ca7aa5e..199fe6f 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -591,6 +591,40 @@ def fail_load_schema(): assert "published-components" not in output +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 + + +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_official_static_command_without_schema_fails_with_actionable_error(monkeypatch, tmp_path): def fail_load_schema(): raise FileNotFoundError("missing tangle_api.schema") From b331332704296dd2e597eb7cac5045241564baab Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 08:47:17 -0700 Subject: [PATCH 031/111] fix: keep safe default API cache lookup --- packages/tangle-cli/src/tangle_cli/api_cli.py | 28 +++++- tests/test_api_cli.py | 90 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index bfc344c..a41199d 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -58,6 +58,7 @@ from .api_transport import ( DEFAULT_API_URL, DEFAULT_TIMEOUT_SECONDS, + _ambient_auth_env_present, _env_header_entries, _headers_from_env, _load_body_argument, @@ -518,6 +519,7 @@ def _schema_for_current_invocation() -> dict[str, Any] | 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) explicit_base_url = _base_url_from_argv(api_tail) @@ -533,7 +535,8 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: ) return cached - cached = load_cached_schema(configured_base_url) if configured_base_url else None + cache_base_url = _auto_cache_base_url(configured_base_url, first_command, 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: @@ -548,6 +551,29 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: return _merge_official_with_cached_extensions(official, cached) +def _auto_cache_base_url( + configured_base_url: str | None, + first_command: str | None, + help_requested: bool, +) -> str | None: + if configured_base_url: + return configured_base_url + if not _ambient_auth_env_present(): + return default_base_url() + if help_requested: + return None + if first_command is not None: + # Preserve the actionable transport guard for real generated command + # dispatch while avoiding an implicit localhost probe for help-only + # invocations with ambient credentials. + return default_base_url() + return None + + +def _api_tail_requests_help(api_tail: list[str]) -> bool: + return any(arg in {"--help", "-h"} for arg in api_tail) + + def _missing_official_schema_message() -> str: return ( "Official static Tangle API commands require the native tangle-api " diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 199fe6f..c719d90 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -591,6 +591,46 @@ def fail_load_schema(): 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 ): @@ -611,6 +651,56 @@ def fail_load_cached_schema(base_url): # pragma: no cover - assertion helper 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() + + 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) From 9ddfdb539c6c3877690cf5de4aed67aa59089259 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 08:32:01 -0700 Subject: [PATCH 032/111] feat: add local component authoring commands --- README.md | 16 + .../src/tangle_cli/component_from_func.py | 1825 ++++++++++++ .../src/tangle_cli/component_generator.py | 171 ++ .../src/tangle_cli/components_cli.py | 181 +- .../src/tangle_cli/module_bundler.py | 652 +++++ .../src/tangle_cli/version_manager.py | 341 +++ pyproject.toml | 2 + .../task_env_strip_annotation_op.py | 28 + .../task_env_strip_body_ref_op.py | 26 + .../task_env_strip_colocated_op.py | 24 + .../python_pipeline/task_env_strip_envs.py | 25 + .../task_env_strip_imported_op.py | 23 + .../task_env_strip_inline_op.py | 22 + .../task_env_strip_mixed_import_op.py | 23 + .../task_env_strip_module_op.py | 23 + .../task_env_strip_nested_import_op.py | 27 + .../task_env_strip_override_op.py | 25 + .../bundle_mode.expected.yaml | 111 + .../complete_generation.expected.yaml | 119 + .../helper_functions.expected.yaml | 84 + tests/test_component_from_func.py | 2484 +++++++++++++++++ tests/test_component_generator.py | 213 ++ tests/test_module_bundler.py | 351 +++ tests/test_tangle_deploy_compat_imports.py | 8 + tests/test_version_manager.py | 309 ++ uv.lock | 6 +- 26 files changed, 7112 insertions(+), 7 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/component_from_func.py create mode 100644 packages/tangle-cli/src/tangle_cli/component_generator.py create mode 100644 packages/tangle-cli/src/tangle_cli/module_bundler.py create mode 100644 packages/tangle-cli/src/tangle_cli/version_manager.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_annotation_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_body_ref_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_colocated_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_envs.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_imported_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_inline_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_mixed_import_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_module_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_nested_import_op.py create mode 100644 tests/fixtures/python_pipeline/task_env_strip_override_op.py create mode 100644 tests/snapshots/component_generator/bundle_mode.expected.yaml create mode 100644 tests/snapshots/component_generator/complete_generation.expected.yaml create mode 100644 tests/snapshots/component_generator/helper_functions.expected.yaml create mode 100644 tests/test_component_from_func.py create mode 100644 tests/test_component_generator.py create mode 100644 tests/test_module_bundler.py create mode 100644 tests/test_version_manager.py diff --git a/README.md b/README.md index bcc70de..5c9623c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ SDK/scaffold commands live under `tangle sdk`. Local component generation/spec h uv run tangle sdk components --help uv run tangle sdk components annotations get uv run tangle sdk components annotations set +uv run tangle sdk components generate from-python path/to/component.py --image python:3.12 +uv run tangle sdk components generate from-python-function path/to/component.py # compatibility alias +uv run tangle sdk components bump-version path/to/component.yaml uv run tangle sdk published-components --help uv run tangle sdk published-components search transformer uv run tangle sdk published-components inspect transformer @@ -32,6 +35,19 @@ uv run tangle sdk published-components inspect --digest sha256:... uv run tangle sdk published-components library ``` +`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. The command accepts `--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. + +Both 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 over config-file values. + ## API commands API commands are pre-generated from the checked-in official Tangle FastAPI/OpenAPI snapshot, so native installs (`tangle-cli[native]`, or development installs with the workspace `tangle-api` package) show resource command groups immediately on a cold cache and command invocations do not require `refresh` first. By default, the CLI uses `--schema-source auto`: official static operations are always present when the native API package is installed, and cached live-backend operations discovered by `tangle api refresh` are included as extensions when they exist. Cached schemas do not override official operations with the same method/path; official definitions win. 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..f53d7ef --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -0,0 +1,1825 @@ +""" +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"): + setattr(python_pipeline_mod, name, _identity_decorator) + for name in ("In", "Out", "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) +# ============================================================================ + +# Decorators that exist purely to *record* a function at authoring time. They +# must never survive into the baked operation program (see +# _strip_authoring_constructs). +_AUTHORING_DECORATOR_NAMES = frozenset({"task", "pipeline", "subpipeline"}) + +# 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. See + # https://github.com/Shopify/discovery/issues/28703. 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, +) -> 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``). + + Returns: + True on success, False on failure. + """ + try: + # 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) + + # 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, regardless of whether the files live in a git repo. + resolved_source = file_path.resolve() + resolved_output = output_path.resolve() + common_dir = Path(os.path.commonpath([resolved_source, resolved_output])) + try: + if not strip_source_path: + annotations["python_original_code_path"] = str(resolved_source.relative_to(common_dir)) + annotations["component_yaml_path"] = str(resolved_output.relative_to(common_dir)) + except ValueError: + annotations["component_yaml_path"] = output_path.name + + # Git info — use the same common ancestor as git_relative_dir. + git_root = get_git_root(directory) + 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, + ) + 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 the real-Oasis run, 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..21d2cd5 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_generator.py @@ -0,0 +1,171 @@ +"""Generate Tangle component YAML files from local Python functions.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +DEFAULT_CONTAINER_IMAGE = "python:3.12" + + +def find_dependencies_file(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( + 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(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 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, +) -> bool: + """Regenerate a YAML component from a Python function source file.""" + + log = print if verbose else lambda *args, **kwargs: None + if not python_file.exists(): + log(f" ❌ File not found: {python_file}") + return False + + final_output = output_path or determine_output_path(python_file) + image = image or _extract_image_from_yaml(final_output) or DEFAULT_CONTAINER_IMAGE + deps_file = dependencies_from or find_dependencies_file(python_file) + if deps_file: + log(f" Found dependencies: {deps_file}") + + final_output.parent.mkdir(parents=True, exist_ok=True) + return _run_generation( + python_file=python_file, + final_output=final_output, + image=image, + func_name=function_name, + deps_file=deps_file, + custom_name=custom_name, + strip_code=strip_code, + strip_source_path=strip_source_path, + log=log, + mode=mode, + resolve_root=resolve_root, + ) + + +def _run_generation( + *, + 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, + log: Any, + mode: str = "inline", + resolve_root: Path | None = None, +) -> bool: + """Execute component generation and clean up partial output on failure.""" + + try: + from tangle_cli.component_from_func import generate_component_yaml + + log(f" Generating component from {python_file.name}{f' function {func_name!r}' if func_name else ''}...") + success = 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, + ) + if not success: + log(" ❌ Failed to generate component") + return False + log(f" ✅ Generated: {final_output}") + return True + except Exception as exc: + log(f" ❌ Error: {exc}") + if final_output.exists(): + final_output.unlink() + return False + + +__all__ = [ + "DEFAULT_CONTAINER_IMAGE", + "determine_output_path", + "find_dependencies_file", + "regenerate_yaml", +] diff --git a/packages/tangle-cli/src/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py index cdfba5c..e13e7f9 100644 --- a/packages/tangle-cli/src/tangle_cli/components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -1,7 +1,10 @@ import pathlib import sys +from typing import Annotated, Any -from cyclopts import App +from cyclopts import App, Parameter + +from .args_container import ArgsContainer, ConfigFileError app = App(name="components", help="Work with Tangle component definitions.") @@ -16,6 +19,23 @@ annotations_app = App(name="annotations", help="Work with component annotations.") app.command(annotations_app) +ConfigOption = Annotated[ + str | None, + Parameter(help="YAML/JSON config file providing command defaults."), +] + + +def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: + try: + return ArgsContainer.load(config, **kwargs) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + + +def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: + return pathlib.Path(value) if value is not None else None + + # region components @@ -87,12 +107,161 @@ def components_generate_from_template( 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, +) -> None: + all_args = _load_args( + 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), + ) + for args in all_args: + from .component_generator import determine_output_path, regenerate_yaml + + 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 = determine_output_path( + python_path, + args.output, + output_is_dir=False, + use_legacy_naming=bool(args.use_legacy_naming), + ) + success = 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, + verbose=True, + ) + if not success: + raise SystemExit(1) + + +@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, +) -> 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, + ) + + @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() +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, +) -> 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, + ) # 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, +) -> None: + """Bump version metadata in a component YAML file.""" + + all_args = _load_args( + config, + yaml_file=("yaml_file", yaml_file, None, False, True, _optional_path), + set_version=(set_version, None), + update_timestamp=(update_timestamp, None), + ) + result: dict[str, Any] = {} + from .version_manager import bump_version + + for args in all_args: + result = bump_version( + args.yaml_file, + set_version=args.set_version, + update_timestamp=bool(args.update_timestamp), + ) + if result.get("status") != "success": + raise SystemExit(1) + if result: + print(result) 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..859c7c1 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/module_bundler.py @@ -0,0 +1,652 @@ +"""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 = {{}} + 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__)""") + + +# ============================================================================= +# 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/version_manager.py b/packages/tangle-cli/src/tangle_cli/version_manager.py new file mode 100644 index 0000000..d157cb2 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/version_manager.py @@ -0,0 +1,341 @@ +"""Version bumping for Tangle component YAML and Python source files.""" + +from __future__ import annotations + +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 +from tangle_cli.component_generator import regenerate_yaml +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, + ) -> bool: + """Update a Python component function docstring Metadata section.""" + + python_path = Path(python_file) + metadata, actual_func_name = extract_file_metadata(python_path) + 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_docstring_metadata(content, 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_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 _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] = [] + component_yaml_path = annotations.get("component_yaml_path") + if component_yaml_path: + yaml_rel = Path(component_yaml_path) + if not yaml_rel.is_absolute(): + common_dir = yaml_path.resolve().parent + for part in yaml_rel.parent.parts: + if part not in ("", "."): + common_dir = common_dir.parent + 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 + + 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, + ) + if success: + log.info(" 🔄 Regenerating YAML...") + success = regenerate_yaml( + python_full_path, + output_path=yaml_path, + strip_code=not has_original_code, + ) + 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 8018e06..8a55147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,11 +12,13 @@ dependencies = [ "cloud-pipelines>=0.26.3.12", "cloud-pipelines-backend", "cyclopts>=4.16.1", + "docstring-parser>=0.16", "httpx>=0.28.1", "platformdirs>=4.10.0", "pydantic>=2.0", "pyyaml>=6.0", "requests>=2.32.0", + "tomli>=2.0; python_version < '3.11'", ] [project.urls] 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..96c70bf --- /dev/null +++ b/tests/snapshots/component_generator/bundle_mode.expected.yaml @@ -0,0 +1,111 @@ +name: My component +description: Clean a name. +metadata: + annotations: + cloud_pipelines.net: 'true' + components new regenerate python-function-component: 'true' + 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..2ccd0e2 --- /dev/null +++ b/tests/snapshots/component_generator/complete_generation.expected.yaml @@ -0,0 +1,119 @@ +name: Data Processor +description: Processes and validates input data. +metadata: + annotations: + cloud_pipelines.net: 'true' + components new regenerate python-function-component: 'true' + 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..2cf8444 --- /dev/null +++ b/tests/snapshots/component_generator/helper_functions.expected.yaml @@ -0,0 +1,84 @@ +name: My component +description: Format and return a name. +metadata: + annotations: + cloud_pipelines.net: 'true' + components new regenerate python-function-component: 'true' + 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_component_from_func.py b/tests/test_component_from_func.py new file mode 100644 index 0000000..2d257b7 --- /dev/null +++ b/tests/test_component_from_func.py @@ -0,0 +1,2484 @@ +"""Tests for the native component YAML generator (component_from_func).""" + +import inspect +import json +import subprocess +import sys +import textwrap +from pathlib import Path +from unittest.mock import patch + +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 https://github.com/Shopify/discovery/issues/28703. + + 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_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) +# ============================================================================ + + +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 / + # real-Oasis run). 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 GCP 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 BigQuery table. + + Args: + project: The GCP 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..9b83b1a --- /dev/null +++ b/tests/test_component_generator.py @@ -0,0 +1,213 @@ +"""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_generator import ( + 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_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_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_module_bundler.py b/tests/test_module_bundler.py new file mode 100644 index 0000000..613f270 --- /dev/null +++ b/tests/test_module_bundler.py @@ -0,0 +1,351 @@ +"""Tests for ``ModuleBundler`` ordering and dependency analysis. + +Focused unit tests for the topological sort introduced to fix +https://github.com/Shopify/discovery/issues/30197 (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_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_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index f81d960..343eacc 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -11,6 +11,10 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: 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.module_bundler import ModuleBundler + from tangle_cli.version_manager import bump_version from tangle_cli.component_inspector import ( get_standard_library, inspect_by_digest, @@ -95,6 +99,10 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert ComponentSpec.__name__ == "ComponentSpec" assert PipelineRun.__name__ == "PipelineRun" assert ArgsContainer and ConfigFileError + assert callable(generate_component_yaml) + assert callable(regenerate_yaml) + assert callable(bump_version) + assert ModuleBundler is not None assert callable(get_standard_library) assert callable(inspect_by_digest) assert callable(inspect_by_name) diff --git a/tests/test_version_manager.py b/tests/test_version_manager.py new file mode 100644 index 0000000..2f5f76f --- /dev/null +++ b/tests/test_version_manager.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Tests for the version manager.""" + +import tempfile +from pathlib import Path + +import 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_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/uv.lock b/uv.lock index 87a8e41..68f1fd3 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-05T03:12:22.361582Z" +exclude-newer = "2026-06-05T15:26:34.402328Z" exclude-newer-span = "P7D" [manifest] @@ -1796,11 +1796,13 @@ dependencies = [ { name = "cloud-pipelines" }, { name = "cloud-pipelines-backend" }, { name = "cyclopts" }, + { name = "docstring-parser" }, { name = "httpx" }, { name = "platformdirs" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "requests" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.optional-dependencies] @@ -1832,12 +1834,14 @@ 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 = ">=4.16.1" }, + { name = "docstring-parser", specifier = ">=0.16" }, { name = "httpx", specifier = ">=0.28.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"] From 496b047cc41d863b2728a7a8c469e81d3f70f851 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 09:02:49 -0700 Subject: [PATCH 033/111] fix: preserve raw run exports and component matching --- packages/tangle-cli/src/tangle_cli/client.py | 16 ++++++-- tests/test_client.py | 40 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 tests/test_client.py diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index b75bce3..63c23f9 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -485,7 +485,7 @@ def add(info: ComponentInfo) -> None: published_by_substring=publisher_filter, name_substring=name, ): - if info.name == name: + if info.name.lower() == name.lower(): add(info) return list(found.values()) @@ -537,8 +537,18 @@ def get_run_details( ) def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None: - details = self.get_run_details(run_id, include_implementations=True) - return details.execution.task_spec if details.execution else None + try: + run = PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) + root_execution_id = run.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 {} diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..2e5e798 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +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() From ebb871ce5d9364cb202b0b761342334b5a945805 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 09:01:01 -0700 Subject: [PATCH 034/111] fix: preserve component generation context on bump --- .../src/tangle_cli/component_from_func.py | 14 ++ .../src/tangle_cli/version_manager.py | 153 +++++++++++-- .../bundle_mode.expected.yaml | 2 + .../complete_generation.expected.yaml | 2 + .../helper_functions.expected.yaml | 2 + tests/test_version_manager.py | 203 ++++++++++++++++++ 6 files changed, 364 insertions(+), 12 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index f53d7ef..91e991f 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -1744,6 +1744,9 @@ def generate_component_yaml( if deps: annotations["python_dependencies"] = json.dumps(deps) + 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 @@ -1751,12 +1754,23 @@ def generate_component_yaml( resolved_source = file_path.resolve() resolved_output = output_path.resolve() common_dir = Path(os.path.commonpath([resolved_source, resolved_output])) + + def _generation_path_annotation(path: Path) -> str: + try: + return str(path.resolve().relative_to(common_dir)) + except ValueError: + return str(path) + try: if not strip_source_path: annotations["python_original_code_path"] = str(resolved_source.relative_to(common_dir)) annotations["component_yaml_path"] = str(resolved_output.relative_to(common_dir)) except ValueError: annotations["component_yaml_path"] = output_path.name + if dependencies_from: + annotations["tangle_cli_generation_dependencies_from"] = _generation_path_annotation(dependencies_from) + if resolve_root: + annotations["tangle_cli_generation_resolve_root"] = _generation_path_annotation(resolve_root) # Git info — use the same common ancestor as git_relative_dir. git_root = get_git_root(directory) diff --git a/packages/tangle-cli/src/tangle_cli/version_manager.py b/packages/tangle-cli/src/tangle_cli/version_manager.py index d157cb2..f3f827a 100644 --- a/packages/tangle-cli/src/tangle_cli/version_manager.py +++ b/packages/tangle-cli/src/tangle_cli/version_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import re from collections.abc import Callable from datetime import datetime, timezone @@ -10,7 +11,7 @@ import yaml from tangle_cli import utils -from tangle_cli.component_from_func import extract_file_metadata +from tangle_cli.component_from_func import extract_file_metadata, find_function_in_source from tangle_cli.component_generator import regenerate_yaml from tangle_cli.logger import Logger, get_default_logger @@ -125,11 +126,15 @@ def update_python_file( 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) - metadata, actual_func_name = extract_file_metadata(python_path) + 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 @@ -175,7 +180,13 @@ def update_python_file( with open(python_file, encoding="utf-8") as f: content = f.read() - new_content = self._update_docstring_metadata(content, final_version, current_timestamp) + 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 @@ -184,6 +195,35 @@ def update_python_file( 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, @@ -209,6 +249,58 @@ def replace_metadata(match: re.Match) -> str: 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. @@ -228,15 +320,9 @@ def _resolve_python_source_path(yaml_path: Path, annotations: dict[str, str]) -> return python_path if python_path.exists() else None candidates: list[Path] = [] - component_yaml_path = annotations.get("component_yaml_path") - if component_yaml_path: - yaml_rel = Path(component_yaml_path) - if not yaml_rel.is_absolute(): - common_dir = yaml_path.resolve().parent - for part in yaml_rel.parent.parts: - if part not in ("", "."): - common_dir = common_dir.parent - candidates.append(common_dir / python_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( @@ -292,6 +378,43 @@ def bump_version( 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) @@ -302,13 +425,19 @@ def bump_version( 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 = 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}") diff --git a/tests/snapshots/component_generator/bundle_mode.expected.yaml b/tests/snapshots/component_generator/bundle_mode.expected.yaml index 96c70bf..932fe71 100644 --- a/tests/snapshots/component_generator/bundle_mode.expected.yaml +++ b/tests/snapshots/component_generator/bundle_mode.expected.yaml @@ -4,6 +4,8 @@ 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 diff --git a/tests/snapshots/component_generator/complete_generation.expected.yaml b/tests/snapshots/component_generator/complete_generation.expected.yaml index 2ccd0e2..4cbf839 100644 --- a/tests/snapshots/component_generator/complete_generation.expected.yaml +++ b/tests/snapshots/component_generator/complete_generation.expected.yaml @@ -4,6 +4,8 @@ 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 diff --git a/tests/snapshots/component_generator/helper_functions.expected.yaml b/tests/snapshots/component_generator/helper_functions.expected.yaml index 2cf8444..5464512 100644 --- a/tests/snapshots/component_generator/helper_functions.expected.yaml +++ b/tests/snapshots/component_generator/helper_functions.expected.yaml @@ -4,6 +4,8 @@ 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 diff --git a/tests/test_version_manager.py b/tests/test_version_manager.py index 2f5f76f..404d42f 100644 --- a/tests/test_version_manager.py +++ b/tests/test_version_manager.py @@ -6,6 +6,7 @@ import yaml +from tangle_cli.component_from_func import generate_component_yaml from tangle_cli.version_manager import bump_version @@ -255,6 +256,208 @@ def test_component(input_value: str) -> str: 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.""" From 47f34c1a1e3afa0ba48b32d63631b9d7289c8488 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 09:56:29 -0700 Subject: [PATCH 035/111] feat: add component publish and deprecate commands --- README.md | 50 +++- .../src/tangle_cli/component_publisher.py | 251 ++++++++++++++++++ .../src/tangle_cli/components_cli.py | 194 ++++++++++++++ tests/test_components_cli.py | 209 +++++++++++++++ tests/test_tangle_deploy_compat_imports.py | 8 + 5 files changed, 708 insertions(+), 4 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/component_publisher.py create mode 100644 tests/test_components_cli.py diff --git a/README.md b/README.md index 5c9623c..9958000 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ uv run tangle sdk components annotations set uv run tangle sdk components generate from-python path/to/component.py --image python:3.12 uv run tangle sdk components generate from-python-function path/to/component.py # compatibility alias uv run tangle sdk components bump-version path/to/component.yaml +uv run tangle sdk components publish path/to/component.yaml --base-url https://api.example +uv run tangle sdk components deprecate sha256:... --superseded-by sha256:... uv run tangle sdk published-components --help uv run tangle sdk published-components search transformer uv run tangle sdk published-components inspect transformer @@ -43,10 +45,50 @@ modules. The command accepts `--function`, `--output`, `--name`, `--image`, in YAML, and updates/regenerates a referenced Python source when the component contains `python_original_code_path` annotations. -Both 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 over config-file values. +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 over config-file values. + +Local components can also be published to, or deprecated in, a Tangle component +registry using the native generated/static API client: + +```bash +uv run tangle sdk components publish components/my-component.yaml \ + --base-url https://api.example \ + --image python:3.12 \ + --name "My component" + +uv run tangle sdk components publish components/my-component.yaml --dry-run +uv run tangle sdk components deprecate sha256:old --superseded-by sha256:new +``` + +`publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), +`--dry-run`, generic git metadata fields, generic API auth fields (`--base-url`, +`--token`, `--auth-header`, `-H/--header`), and `--config`. `deprecate` accepts +`--superseded-by`, the same generic API auth fields, and `--config`. These are +single-component OSS commands; batch `publish-all`, dbt generation, +from-container generation, and backend-specific search-v2 workflows remain out +of this lab CLI slice. + +Example publish config: + +```yaml +component_path: components/my-component.yaml +image: python:3.12 +name: My component +annotations: + owner: platform +base_url: https://api.example +``` + +Example deprecate config: + +```yaml +digest: sha256:old +superseded_by: sha256:new +base_url: https://api.example +``` ## API commands 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..8a4d8a6 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -0,0 +1,251 @@ +"""Publish and deprecate local Tangle component definitions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from .logger import Logger, get_default_logger + +if TYPE_CHECKING: + from tangle_api.generated.models import ComponentSpec + + +@dataclass +class ComponentPublishResult: + """Structured result for publishing one component.""" + + status: str + component_path: str + name: str | None = None + digest: str | None = None + dry_run: bool = False + response: Any = None + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "component_path": self.component_path, + "name": self.name, + "digest": self.digest, + "dry_run": self.dry_run, + "response": _to_plain(self.response), + "error": self.error, + } + + +@dataclass +class ComponentDeprecationResult: + """Structured result for deprecating one published component.""" + + status: str + digest: str + superseded_by: str | None = None + response: Any = None + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "digest": self.digest, + "superseded_by": self.superseded_by, + "response": _to_plain(self.response), + "error": self.error, + } + + +def load_component_spec( + component_path: str | Path, + *, + annotations: Mapping[str, str] | None = None, +) -> "ComponentSpec": + """Load a component YAML file into the generated ``ComponentSpec`` model.""" + + from tangle_api.generated.models import ComponentSpec + + path = Path(component_path) + text = path.read_text(encoding="utf-8") + return ComponentSpec.from_yaml(text, annotations=dict(annotations or {})) + + +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.""" + + path = Path(component_path) + spec = load_component_spec(path, annotations=annotations) + if name: + spec.name = name + spec.data["name"] = name + if description: + spec.description = description + spec.data["description"] = description + + component_yaml_path = _component_yaml_path(path, git_root) + spec.update_fields( + git_remote_sha=git_remote_sha, + git_remote_branch=git_remote_branch, + git_remote_url=git_remote_url, + image=image, + component_yaml_path=component_yaml_path, + ) + return spec + + +def publish_component( + client: Any, + component_path: str | Path, + *, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: Mapping[str, str] | None = None, + dry_run: bool = False, + git_remote_sha: str | None = None, + git_remote_branch: str | None = None, + git_remote_url: str | None = None, + git_root: str | Path | None = None, + logger: Logger | None = None, +) -> ComponentPublishResult: + """Publish one component YAML file using a static Tangle API client.""" + + log = logger or get_default_logger() + path = Path(component_path) + try: + spec = prepare_component_for_publish( + path, + image=image, + name=name, + description=description, + annotations=annotations, + git_remote_sha=git_remote_sha, + git_remote_branch=git_remote_branch, + git_remote_url=git_remote_url, + git_root=git_root, + ) + except Exception as exc: + log.error(f"❌ Failed to load component {path}: {exc}") + return ComponentPublishResult( + status="failed", + component_path=str(path), + dry_run=dry_run, + error=str(exc), + ) + + yaml_text = spec.to_yaml() + if dry_run: + log.info(f"[DRY-RUN] Would publish component: {spec.name}") + return ComponentPublishResult( + status="dry_run", + component_path=str(path), + name=spec.name, + dry_run=True, + response={"name": spec.name, "text": yaml_text}, + ) + + try: + response = client.published_components_create(name=spec.name, text=yaml_text) + digest = _extract_digest(response) + log.info(f"✅ Published component {spec.name}" + (f" ({digest[:16]}...)" if digest else "")) + return ComponentPublishResult( + status="success", + component_path=str(path), + name=spec.name, + digest=digest, + response=response, + ) + except Exception as exc: + log.error(f"❌ Failed to publish component {spec.name}: {exc}") + return ComponentPublishResult( + status="failed", + component_path=str(path), + name=spec.name, + error=str(exc), + ) + + +def deprecate_component( + client: Any, + digest: str, + *, + superseded_by: str | None = None, + logger: Logger | None = None, +) -> ComponentDeprecationResult: + """Mark a published component as deprecated by digest.""" + + log = logger or get_default_logger() + try: + response = client.published_components_update( + digest=digest, + deprecated=True, + superseded_by=superseded_by, + ) + log.info(f"✅ Deprecated component {digest[:16]}...") + if superseded_by: + log.info(f" Superseded by: {superseded_by[:16]}...") + return ComponentDeprecationResult( + status="success", + digest=digest, + superseded_by=superseded_by, + response=response, + ) + except Exception as exc: + log.error(f"❌ Failed to deprecate component {digest[:16]}...: {exc}") + return ComponentDeprecationResult( + status="failed", + digest=digest, + superseded_by=superseded_by, + error=str(exc), + ) + + +def _component_yaml_path(component_path: Path, git_root: str | Path | None) -> str | None: + if git_root is None: + return None + try: + return str(component_path.resolve().relative_to(Path(git_root).resolve())) + except ValueError: + return None + + +def _extract_digest(response: Any) -> str | None: + plain = _to_plain(response) + if isinstance(plain, Mapping): + digest = plain.get("digest") + return str(digest) if digest else None + return None + + +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__ = [ + "ComponentDeprecationResult", + "ComponentPublishResult", + "deprecate_component", + "load_component_spec", + "prepare_component_for_publish", + "publish_component", +] diff --git a/packages/tangle-cli/src/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py index e13e7f9..b2b7112 100644 --- a/packages/tangle-cli/src/tangle_cli/components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -1,9 +1,11 @@ +import json import pathlib import sys from typing import Annotated, Any from cyclopts import App, Parameter +from .api_transport import DEFAULT_TIMEOUT_SECONDS from .args_container import ArgsContainer, ConfigFileError app = App(name="components", help="Work with Tangle component definitions.") @@ -23,6 +25,31 @@ str | None, Parameter(help="YAML/JSON config file providing command defaults."), ] +BaseUrlOption = Annotated[ + str | None, + Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), +] +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( + alias="-H", + help="Custom request header as 'Name: value'. Repeat for multiple.", + negative_iterable=(), + ), +] def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: @@ -36,6 +63,64 @@ def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: return pathlib.Path(value) if value is not None else 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, ...]]: + return { + "base_url": (base_url, None), + "token": (token, None), + "auth_header": (auth_header, None), + "header": (header, None), + } + + +def _client_from_options( + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, +) -> Any: + try: + from .client import TangleApiClient + except ModuleNotFoundError as exc: + if exc.name == "tangle_api": + raise SystemExit( + "Native generated Tangle API bindings are required for component " + "publish/deprecate commands. Install tangle-cli[native] or " + "provide a local tangle_api.generated package." + ) from exc + raise + + return TangleApiClient( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + +def _print_json(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def publish_component(*args: Any, **kwargs: Any) -> Any: + from .component_publisher import publish_component as _publish_component + + return _publish_component(*args, **kwargs) + + +def deprecate_component(*args: Any, **kwargs: Any) -> Any: + from .component_publisher import deprecate_component as _deprecate_component + + return _deprecate_component(*args, **kwargs) + + # region components @@ -265,3 +350,112 @@ def components_bump_version( raise SystemExit(1) if result: print(result) + + +@app.command(name="publish") +def 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, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, +) -> None: + """Publish one component YAML file to a Tangle component registry.""" + + for args in _load_args( + 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), + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + 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, + ) + result = publish_component( + client, + args.component_path, + image=args.image, + name=args.name, + description=args.description, + annotations=args.annotations, + 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, + ) + result_dict = result.to_dict() if hasattr(result, "to_dict") else result + _print_json(result_dict) + if isinstance(result_dict, dict) and result_dict.get("status") == "failed": + raise SystemExit(1) + + +@app.command(name="deprecate") +def 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, +) -> None: + """Deprecate a published component by digest.""" + + for args in _load_args( + config, + digest=("digest", digest, None, False, True), + superseded_by=(superseded_by, None), + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + ) + result = deprecate_component( + client, + args.digest, + superseded_by=args.superseded_by, + ) + result_dict = result.to_dict() if hasattr(result, "to_dict") else result + _print_json(result_dict) + if isinstance(result_dict, dict) and result_dict.get("status") == "failed": + raise SystemExit(1) diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py new file mode 100644 index 0000000..50c60d0 --- /dev/null +++ b/tests/test_components_cli.py @@ -0,0 +1,209 @@ +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from tangle_cli import cli, components_cli +from tangle_cli.component_publisher import deprecate_component, publish_component + + +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 FakeClient: + def __init__(self) -> None: + self.published_calls: list[dict[str, Any]] = [] + self.deprecate_calls: list[dict[str, Any]] = [] + + def published_components_create(self, **kwargs: Any) -> dict[str, Any]: + self.published_calls.append(kwargs) + return {"digest": "sha256:abc123", "name": kwargs.get("name")} + + def published_components_update(self, **kwargs: Any) -> dict[str, Any]: + self.deprecate_calls.append(kwargs) + return {"digest": kwargs.get("digest"), "deprecated": kwargs.get("deprecated")} + + +def test_publish_component_calls_generated_create_with_loaded_spec(tmp_path: Path): + component_path = _write_component(tmp_path / "component.yaml", name="Original") + client = FakeClient() + + result = publish_component( + client, + component_path, + image="python:3.13", + name="Published Name", + description="Published description", + annotations={"owner": "oss"}, + ) + + assert result.status == "success" + assert result.digest == "sha256:abc123" + assert client.published_calls == [ + { + "name": "Published Name", + "text": client.published_calls[0]["text"], + } + ] + payload = yaml.safe_load(client.published_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"] + + +def test_publish_component_dry_run_does_not_call_api(tmp_path: Path): + component_path = _write_component(tmp_path / "component.yaml", name="Dry Run") + client = FakeClient() + + result = publish_component(client, component_path, dry_run=True) + + assert result.status == "dry_run" + assert result.dry_run is True + assert client.published_calls == [] + assert result.response["name"] == "Dry Run" + + +def test_deprecate_component_calls_generated_update(): + client = FakeClient() + + result = deprecate_component(client, "sha256:old", superseded_by="sha256:new") + + assert result.status == "success" + assert client.deprecate_calls == [ + {"digest": "sha256:old", "deprecated": True, "superseded_by": "sha256:new"} + ] + + +def test_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", + ) + calls: list[dict[str, Any]] = [] + + def fake_client_from_options(**kwargs: Any) -> object: + raise AssertionError("dry-run publish must not create an API client") + + def fake_publish_component(client: object, component_path: Path, **kwargs: Any) -> dict[str, Any]: + calls.append({"client": client, "component_path": component_path, **kwargs}) + return {"status": "dry_run", "name": kwargs["name"], "dry_run": kwargs["dry_run"]} + + monkeypatch.setattr(components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(components_cli, "publish_component", fake_publish_component) + + app = cli.build_app() + run_app( + app, + ["sdk", "components", "publish", "--config", str(config), "--name", "CLI Name"], + ) + + result = json.loads(capsys.readouterr().out) + assert result["status"] == "dry_run" + assert result["name"] == "CLI Name" + assert calls == [ + { + "client": None, + "component_path": component_path, + "image": None, + "name": "CLI Name", + "description": None, + "annotations": {"from_config": True}, + "dry_run": True, + "git_remote_sha": None, + "git_remote_branch": None, + "git_remote_url": None, + "git_root": None, + } + ] + + +def test_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 {"status": "success", "digest": digest, "superseded_by": kwargs.get("superseded_by")} + + monkeypatch.setattr(components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(components_cli, "deprecate_component", fake_deprecate_component) + + app = cli.build_app() + run_app(app, ["sdk", "components", "deprecate", "--config", str(config)]) + + result = json.loads(capsys.readouterr().out) + assert result == { + "digest": "sha256:from-config", + "status": "success", + "superseded_by": "sha256:new", + } + assert client_calls == [ + {"base_url": "https://api.test", "token": None, "auth_header": None, "header": ["X-Test: yes"]} + ] + assert deprecate_calls == [ + {"client": fake_client, "digest": "sha256:from-config", "superseded_by": "sha256:new"} + ] + + +def test_components_help_excludes_publish_all_and_shopify_slack_options(capsys): + app = cli.build_app() + + run_app(app, ["sdk", "components", "--help"]) + output = capsys.readouterr().out + assert "publish" in output + assert "deprecate" in output + assert "publish-all" not in output + + run_app(app, ["sdk", "components", "publish", "--help"]) + publish_help = capsys.readouterr().out + assert "slack" not in publish_help.lower() + assert "shopify" not in publish_help.lower() + assert "publish-all" not in publish_help + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "components", "publish-all"]) + assert exc_info.value.code != 0 diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 343eacc..9053c50 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -13,6 +13,11 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: 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 ( + deprecate_component, + prepare_component_for_publish, + publish_component, + ) from tangle_cli.module_bundler import ModuleBundler from tangle_cli.version_manager import bump_version from tangle_cli.component_inspector import ( @@ -101,6 +106,9 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert ArgsContainer and ConfigFileError assert callable(generate_component_yaml) assert callable(regenerate_yaml) + assert callable(prepare_component_for_publish) + assert callable(publish_component) + assert callable(deprecate_component) assert callable(bump_version) assert ModuleBundler is not None assert callable(get_standard_library) From 50530a83daee284970f64c2a7c114aa102538a12 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 10:02:03 -0700 Subject: [PATCH 036/111] fix: avoid run export root id roundtrip --- packages/tangle-cli/src/tangle_cli/client.py | 6 ++++-- tests/test_client.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index 63c23f9..ff1a926 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -538,8 +538,10 @@ def get_run_details( def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None: try: - run = PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id))) - root_execution_id = run.root_execution_id + 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 diff --git a/tests/test_client.py b/tests/test_client.py index 2e5e798..5456ac0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ 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 @@ -38,3 +39,17 @@ def test_get_run_pipeline_spec_fetches_raw_root_execution_without_enrichment() - 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") From 4254862865f3d9408fd2f604b6f487862387df8b Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 10:09:18 -0700 Subject: [PATCH 037/111] refactor: move publish commands under published components --- README.md | 14 +- .../src/tangle_cli/components_cli.py | 193 ------------------ .../tangle_cli/published_components_cli.py | 126 ++++++++++++ tests/test_components_cli.py | 54 +++-- 4 files changed, 170 insertions(+), 217 deletions(-) diff --git a/README.md b/README.md index 9958000..4049d40 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ uv run tangle sdk published-components --help ## SDK commands -SDK/scaffold commands live under `tangle sdk`. Local component generation/spec helpers are intentionally nested under `sdk components`; root-level `tangle components ...` is not registered in this lab CLI. Published/registry component inspection lives separately under `sdk published-components` so local component authoring and published component lookup do not share the same command group. +SDK/scaffold commands live under `tangle sdk`. Local component generation/spec helpers are intentionally nested under `sdk components`; root-level `tangle components ...` is not registered in this lab CLI. API-backed published/registry component operations live separately under `sdk published-components` so local component authoring and registry calls do not share the same command group. ```bash uv run tangle sdk components --help @@ -28,13 +28,13 @@ uv run tangle sdk components annotations set uv run tangle sdk components generate from-python path/to/component.py --image python:3.12 uv run tangle sdk components generate from-python-function path/to/component.py # compatibility alias uv run tangle sdk components bump-version path/to/component.yaml -uv run tangle sdk components publish path/to/component.yaml --base-url https://api.example -uv run tangle sdk components deprecate sha256:... --superseded-by sha256:... uv run tangle sdk published-components --help uv run tangle sdk published-components search transformer uv run tangle sdk published-components inspect transformer uv run tangle sdk published-components inspect --digest sha256:... uv run tangle sdk published-components library +uv run tangle sdk published-components publish path/to/component.yaml --base-url https://api.example +uv run tangle sdk published-components deprecate sha256:... --superseded-by sha256:... ``` `generate from-python` converts a local Python function into a component YAML @@ -51,16 +51,16 @@ Generation and version-bump commands accept `--config` YAML/JSON files via `update_timestamp`; explicit CLI values take precedence over config-file values. Local components can also be published to, or deprecated in, a Tangle component -registry using the native generated/static API client: +registry using the native generated/static API client under `sdk published-components`: ```bash -uv run tangle sdk components publish components/my-component.yaml \ +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 components publish components/my-component.yaml --dry-run -uv run tangle sdk components deprecate sha256:old --superseded-by sha256:new +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), diff --git a/packages/tangle-cli/src/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py index b2b7112..1f65f8e 100644 --- a/packages/tangle-cli/src/tangle_cli/components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -1,11 +1,9 @@ -import json import pathlib import sys from typing import Annotated, Any from cyclopts import App, Parameter -from .api_transport import DEFAULT_TIMEOUT_SECONDS from .args_container import ArgsContainer, ConfigFileError app = App(name="components", help="Work with Tangle component definitions.") @@ -25,31 +23,6 @@ str | None, Parameter(help="YAML/JSON config file providing command defaults."), ] -BaseUrlOption = Annotated[ - str | None, - Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), -] -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( - alias="-H", - help="Custom request header as 'Name: value'. Repeat for multiple.", - negative_iterable=(), - ), -] def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: @@ -63,63 +36,6 @@ def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: return pathlib.Path(value) if value is not None else 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, ...]]: - return { - "base_url": (base_url, None), - "token": (token, None), - "auth_header": (auth_header, None), - "header": (header, None), - } - - -def _client_from_options( - *, - base_url: str | None = None, - token: str | None = None, - auth_header: str | None = None, - header: list[str] | None = None, -) -> Any: - try: - from .client import TangleApiClient - except ModuleNotFoundError as exc: - if exc.name == "tangle_api": - raise SystemExit( - "Native generated Tangle API bindings are required for component " - "publish/deprecate commands. Install tangle-cli[native] or " - "provide a local tangle_api.generated package." - ) from exc - raise - - return TangleApiClient( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - -def _print_json(payload: object) -> None: - print(json.dumps(payload, indent=2, sort_keys=True)) - - -def publish_component(*args: Any, **kwargs: Any) -> Any: - from .component_publisher import publish_component as _publish_component - - return _publish_component(*args, **kwargs) - - -def deprecate_component(*args: Any, **kwargs: Any) -> Any: - from .component_publisher import deprecate_component as _deprecate_component - - return _deprecate_component(*args, **kwargs) - # region components @@ -350,112 +266,3 @@ def components_bump_version( raise SystemExit(1) if result: print(result) - - -@app.command(name="publish") -def 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, - base_url: BaseUrlOption = None, - token: TokenOption = None, - auth_header: AuthHeaderOption = None, - header: HeaderOption = None, - config: ConfigOption = None, -) -> None: - """Publish one component YAML file to a Tangle component registry.""" - - for args in _load_args( - 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), - **_api_arg_specs( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - ), - ): - 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, - ) - result = publish_component( - client, - args.component_path, - image=args.image, - name=args.name, - description=args.description, - annotations=args.annotations, - 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, - ) - result_dict = result.to_dict() if hasattr(result, "to_dict") else result - _print_json(result_dict) - if isinstance(result_dict, dict) and result_dict.get("status") == "failed": - raise SystemExit(1) - - -@app.command(name="deprecate") -def 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, -) -> None: - """Deprecate a published component by digest.""" - - for args in _load_args( - config, - digest=("digest", digest, None, False, True), - superseded_by=(superseded_by, None), - **_api_arg_specs( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - ), - ): - client = _client_from_options( - base_url=args.base_url, - token=args.token, - auth_header=args.auth_header, - header=args.header, - ) - result = deprecate_component( - client, - args.digest, - superseded_by=args.superseded_by, - ) - result_dict = result.to_dict() if hasattr(result, "to_dict") else result - _print_json(result_dict) - if isinstance(result_dict, dict) and result_dict.get("status") == "failed": - raise SystemExit(1) diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 21f1c60..050d455 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import pathlib from typing import Annotated, Any from cyclopts import App, Parameter @@ -86,6 +87,10 @@ def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: raise SystemExit(f"Config error: {exc}") from exc +def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: + return pathlib.Path(value) if value is not None else None + + def _api_arg_specs( *, base_url: str | None = None, @@ -125,6 +130,18 @@ def get_standard_library(*args: Any, **kwargs: Any) -> Any: return _get_standard_library(*args, **kwargs) +def publish_component(*args: Any, **kwargs: Any) -> Any: + from .component_publisher import publish_component as _publish_component + + return _publish_component(*args, **kwargs) + + +def deprecate_component(*args: Any, **kwargs: Any) -> Any: + from .component_publisher import deprecate_component as _deprecate_component + + return _deprecate_component(*args, **kwargs) + + @app.command(name="search") def published_components_search( name: str | None = None, @@ -259,3 +276,112 @@ def published_components_library( header=args.header, ) _print_json(get_standard_library(client)) + + +@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, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, +) -> None: + """Publish one component YAML file to a Tangle component registry.""" + + for args in _load_args( + 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), + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + 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, + ) + result = publish_component( + client, + args.component_path, + image=args.image, + name=args.name, + description=args.description, + annotations=args.annotations, + 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, + ) + result_dict = result.to_dict() if hasattr(result, "to_dict") else result + _print_json(result_dict) + if isinstance(result_dict, dict) and result_dict.get("status") == "failed": + 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, +) -> None: + """Deprecate a published component by digest.""" + + for args in _load_args( + config, + digest=("digest", digest, None, False, True), + superseded_by=(superseded_by, None), + **_api_arg_specs( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + ), + ): + client = _client_from_options( + base_url=args.base_url, + token=args.token, + auth_header=args.auth_header, + header=args.header, + ) + result = deprecate_component( + client, + args.digest, + superseded_by=args.superseded_by, + ) + result_dict = result.to_dict() if hasattr(result, "to_dict") else result + _print_json(result_dict) + if isinstance(result_dict, dict) and result_dict.get("status") == "failed": + raise SystemExit(1) diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 50c60d0..02781be 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -5,7 +5,7 @@ import pytest import yaml -from tangle_cli import cli, components_cli +from tangle_cli import cli, published_components_cli from tangle_cli.component_publisher import deprecate_component, publish_component @@ -98,7 +98,7 @@ def test_deprecate_component_calls_generated_update(): ] -def test_components_publish_cli_wiring_and_config_precedence(monkeypatch, tmp_path: Path, capsys): +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( @@ -118,13 +118,13 @@ def fake_publish_component(client: object, component_path: Path, **kwargs: Any) calls.append({"client": client, "component_path": component_path, **kwargs}) return {"status": "dry_run", "name": kwargs["name"], "dry_run": kwargs["dry_run"]} - monkeypatch.setattr(components_cli, "_client_from_options", fake_client_from_options) - monkeypatch.setattr(components_cli, "publish_component", fake_publish_component) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "publish_component", fake_publish_component) app = cli.build_app() run_app( app, - ["sdk", "components", "publish", "--config", str(config), "--name", "CLI Name"], + ["sdk", "published-components", "publish", "--config", str(config), "--name", "CLI Name"], ) result = json.loads(capsys.readouterr().out) @@ -147,7 +147,7 @@ def fake_publish_component(client: object, component_path: Path, **kwargs: Any) ] -def test_components_deprecate_cli_wiring_and_config(monkeypatch, tmp_path: Path, capsys): +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" @@ -169,11 +169,11 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict deprecate_calls.append({"client": client, "digest": digest, **kwargs}) return {"status": "success", "digest": digest, "superseded_by": kwargs.get("superseded_by")} - monkeypatch.setattr(components_cli, "_client_from_options", fake_client_from_options) - monkeypatch.setattr(components_cli, "deprecate_component", fake_deprecate_component) + 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", "components", "deprecate", "--config", str(config)]) + run_app(app, ["sdk", "published-components", "deprecate", "--config", str(config)]) result = json.loads(capsys.readouterr().out) assert result == { @@ -189,21 +189,41 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict ] -def test_components_help_excludes_publish_all_and_shopify_slack_options(capsys): +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 "publish" in output - assert "deprecate" in output + 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 - - run_app(app, ["sdk", "components", "publish", "--help"]) + 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 "slack" not in publish_help.lower() assert "shopify" not in publish_help.lower() assert "publish-all" not in publish_help - with pytest.raises(SystemExit) as exc_info: - app(["sdk", "components", "publish-all"]) - assert exc_info.value.code != 0 + 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 From b3fe6f2633a1d11bd1557e5f78907bc79a04accd Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 10:45:26 -0700 Subject: [PATCH 038/111] fix: forward auth during API schema bootstrap --- packages/tangle-cli/src/tangle_cli/api_cli.py | 35 +++++- .../src/tangle_cli/openapi/parser.py | 27 +++-- tests/test_api_cli.py | 102 ++++++++++++++++++ tests/test_codegen.py | 7 ++ 4 files changed, 159 insertions(+), 12 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index a41199d..12702f0 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -522,6 +522,9 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: help_requested = _api_tail_requests_help(api_tail) schema_source = _schema_source_from_argv(api_tail) + token = _token_from_argv(api_tail) + auth_header = _auth_header_from_argv(api_tail) + header = _headers_from_argv(api_tail) explicit_base_url = _base_url_from_argv(api_tail) configured_base_url = explicit_base_url or os.environ.get("TANGLE_API_URL") if schema_source == "cache": @@ -542,6 +545,16 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: 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, + ) + 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": @@ -722,11 +735,19 @@ def _base_url_from_argv(argv: list[str]) -> str | None: def _token_from_argv(argv: list[str]) -> str | None: - return _option_from_argv(argv, "--token") or default_token() + return ( + _option_from_argv(argv, "--token") + or _optional_str(_config_value_from_argv(argv, "token")) + or default_token() + ) def _auth_header_from_argv(argv: list[str]) -> str | None: - return _option_from_argv(argv, "--auth-header") or default_auth_header() + return ( + _option_from_argv(argv, "--auth-header") + or _optional_str(_config_value_from_argv(argv, "auth_header")) + or default_auth_header() + ) def _config_value_from_argv(argv: list[str], key: str) -> Any: @@ -753,7 +774,15 @@ def _headers_from_argv(argv: list[str]) -> list[str]: entries.append(argv[index + 1]) elif arg.startswith("--header="): entries.append(arg.split("=", 1)[1]) - return entries + 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: diff --git a/packages/tangle-cli/src/tangle_cli/openapi/parser.py b/packages/tangle-cli/src/tangle_cli/openapi/parser.py index 2376413..b5ebcd8 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/parser.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/parser.py @@ -18,6 +18,7 @@ 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]: @@ -32,23 +33,31 @@ def _load_default_openapi_schema() -> dict[str, Any]: if DEFAULT_OPENAPI_PATH.exists(): return _load_json_file(DEFAULT_OPENAPI_PATH) - try: - schema_text = ( - resources.files(DEFAULT_OPENAPI_RESOURCE_PACKAGE) - .joinpath(DEFAULT_OPENAPI_RESOURCE_NAME) - .read_text(encoding="utf-8") - ) - except ModuleNotFoundError as exc: + 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 exc + ) from last_error schema = json.loads(schema_text) if not isinstance(schema, dict) or "paths" not in schema: raise ValueError( - f"{DEFAULT_OPENAPI_RESOURCE_PACKAGE}/{DEFAULT_OPENAPI_RESOURCE_NAME} " + f"{schema_package}/{DEFAULT_OPENAPI_RESOURCE_NAME} " "does not look like an OpenAPI schema" ) return schema diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index c719d90..db83183 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -715,6 +715,108 @@ def test_generated_command_with_ambient_auth_still_requires_explicit_base_url(mo 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"], + } + ] + + +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"], + } + ] + + def test_official_static_command_without_schema_fails_with_actionable_error(monkeypatch, tmp_path): def fail_load_schema(): raise FileNotFoundError("missing tangle_api.schema") diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 63753f9..34d46cb 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -31,6 +31,13 @@ def test_generate_operations_rejects_network_path_references() -> None: 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" From dbaaaf7f010ef71d25152c2d98c5d0f26905a6d8 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 11:30:13 -0700 Subject: [PATCH 039/111] fix: harden API config credential handling --- packages/tangle-cli/src/tangle_cli/api_cli.py | 75 +++++++++++++------ .../tangle-cli/src/tangle_cli/api_schema.py | 30 +++++++- .../src/tangle_cli/api_transport.py | 28 +++++-- tests/test_api_cli.py | 72 ++++++++++++++++++ 4 files changed, 169 insertions(+), 36 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index 12702f0..06377ed 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -419,8 +419,17 @@ def _invoke_operation(operation: OperationCommand, values: dict[str, Any]) -> No """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): - _invoke_operation_once(operation, args.to_dict()) + 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( @@ -463,10 +472,18 @@ def _operation_args_from_config( return resolved -def _invoke_operation_once(operation: OperationCommand, values: dict[str, Any]) -> None: +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) or default_token() + 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 @@ -481,7 +498,8 @@ def _invoke_operation_once(operation: OperationCommand, values: dict[str, Any]) header_entries=header_entries, body=body_arg, timeout=DEFAULT_TIMEOUT_SECONDS, - allow_body_file_references=True, + 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 @@ -522,11 +540,12 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: help_requested = _api_tail_requests_help(api_tail) schema_source = _schema_source_from_argv(api_tail) - token = _token_from_argv(api_tail) - auth_header = _auth_header_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) - explicit_base_url = _base_url_from_argv(api_tail) - configured_base_url = explicit_base_url or os.environ.get("TANGLE_API_URL") if schema_source == "cache": base_url = configured_base_url or default_base_url() cached = load_cached_schema(base_url) @@ -552,6 +571,7 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: 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 @@ -727,27 +747,34 @@ def _schema_fetch_failure_message(base_url: str, exc: Exception) -> str: def _base_url_from_argv(argv: list[str]) -> str | None: - return ( - _option_from_argv(argv, "--base-url") - or _option_from_argv(argv, "--api-url") - or _optional_str(_config_value_from_argv(argv, "base_url")) - ) + value, _source = _base_url_with_source_from_argv(argv) + return value -def _token_from_argv(argv: list[str]) -> str | None: - return ( - _option_from_argv(argv, "--token") - or _optional_str(_config_value_from_argv(argv, "token")) - or default_token() - ) +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 _auth_header_from_argv(argv: list[str]) -> str | None: - return ( - _option_from_argv(argv, "--auth-header") - or _optional_str(_config_value_from_argv(argv, "auth_header")) - or default_auth_header() +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: diff --git a/packages/tangle-cli/src/tangle_cli/api_schema.py b/packages/tangle-cli/src/tangle_cli/api_schema.py index 9247e4b..5475ea8 100644 --- a/packages/tangle-cli/src/tangle_cli/api_schema.py +++ b/packages/tangle-cli/src/tangle_cli/api_schema.py @@ -19,7 +19,6 @@ _openapi_url, _request_headers, default_base_url, - default_token, ) SUPPORTED_METHODS = {"get", "post", "put", "patch", "delete"} @@ -122,13 +121,20 @@ def fetch_schema( 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 or default_token(), header, auth_header, headers), + headers=_request_headers( + token, + header, + auth_header, + headers, + include_env_credentials=include_env_credentials, + ), timeout=DEFAULT_TIMEOUT_SECONDS, ) response.raise_for_status() @@ -145,11 +151,19 @@ def refresh_schema( 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) + 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 @@ -160,13 +174,21 @@ def load_or_fetch_schema( 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) + schema, _ = refresh_schema( + base_url, + token, + header, + auth_header, + headers, + include_env_credentials=include_env_credentials, + ) return schema diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index 841fedc..2c6071c 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -68,6 +68,8 @@ def _request_headers( 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. @@ -77,13 +79,14 @@ def _request_headers( """ headers = {"Accept": "application/json"} - 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 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: @@ -172,6 +175,7 @@ def request_operation( 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. @@ -190,6 +194,7 @@ def request_operation( headers=headers, body=body, allow_body_file_references=allow_body_file_references, + include_env_credentials=include_env_credentials, ) response = httpx.request( method, @@ -213,6 +218,7 @@ def build_operation_request( 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.""" @@ -274,7 +280,13 @@ def build_operation_request( 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) + 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" diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index db83183..c999e1c 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -761,6 +761,7 @@ def fake_load_or_fetch_schema(base_url, **kwargs): "token": "cli-token", "auth_header": "Basic abc", "header": ["X-Trace: 1", "X-Other: 2"], + "include_env_credentials": True, } ] @@ -813,6 +814,49 @@ def fake_load_or_fetch_schema(base_url, **kwargs): "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, } ] @@ -1440,6 +1484,34 @@ def fake_request(method, url, **kwargs): 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 = [] From de9fbafd08a0a531357503f1ca4e0bf4b452c1f5 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 11:37:05 -0700 Subject: [PATCH 040/111] fix: suppress env auth during config refresh --- packages/tangle-cli/src/tangle_cli/api_cli.py | 2 + tests/test_api_cli.py | 58 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index 06377ed..110ca11 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -224,6 +224,7 @@ def refresh( 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() ) @@ -233,6 +234,7 @@ def refresh( 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}" diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index c999e1c..decf597 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -1200,12 +1200,13 @@ def test_refresh_uses_config_with_cli_precedence(monkeypatch, tmp_path, capsys): encoding="utf-8", ) - def fake_refresh_schema(base_url, token, header, auth_header): + 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 @@ -1229,10 +1230,65 @@ def fake_refresh_schema(base_url, token, header, auth_header): "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" From 298f1f8e4966cb5fbb758051be69bdee238ac18e Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 14:16:39 -0700 Subject: [PATCH 041/111] feat: add local pipeline commands --- packages/tangle-cli/src/tangle_cli/cli.py | 2 + packages/tangle-cli/src/tangle_cli/client.py | 6 +- .../src/tangle_cli/pipeline_hydrator.py | 1102 +++++++++++++++++ .../tangle-cli/src/tangle_cli/pipelines.py | 568 +++++++++ .../src/tangle_cli/pipelines_cli.py | 234 ++++ pyproject.toml | 1 + tests/test_pipelines_cli.py | 525 ++++++++ uv.lock | 2 + 8 files changed, 2438 insertions(+), 2 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py create mode 100644 packages/tangle-cli/src/tangle_cli/pipelines.py create mode 100644 packages/tangle-cli/src/tangle_cli/pipelines_cli.py create mode 100644 tests/test_pipelines_cli.py diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py index 2af7ccb..643aee6 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -2,6 +2,7 @@ from . import api_cli from . import components_cli +from . import pipelines_cli from . import published_components_cli @@ -13,6 +14,7 @@ def build_sdk_app() -> App: help="Work with local Tangle SDK resources and scaffolding.", ) sdk_app.command(components_cli.app) + sdk_app.command(pipelines_cli.app) sdk_app.command(published_components_cli.app) return sdk_app diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index ff1a926..d1dc144 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -23,7 +23,6 @@ _normalize_base_url, _request_headers, default_base_url, - default_token, ) from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse from tangle_api.generated.operations import GeneratedTangleApiOperations @@ -63,6 +62,7 @@ def __init__( 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()) self.logger = logger or _null_logger @@ -73,6 +73,7 @@ def __init__( 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.""" @@ -293,10 +294,11 @@ def _headers(self, extra_headers: Mapping[str, str] | None = None) -> dict[str, if extra_headers: headers.update({name: str(value) for name, value in extra_headers.items()}) return _request_headers( - self.token or default_token(), + self.token, self.header, self.auth_header, headers, + include_env_credentials=self.include_env_credentials, ) def _url(self, path: str) -> str: 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..d1cdf0a --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -0,0 +1,1102 @@ +"""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. Shopify/Oasis-only 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 pathlib import Path +from typing import Any + +import yaml +from jinja2 import Environment, FileSystemLoader + +from . import utils +from .api_transport import DEFAULT_TIMEOUT_SECONDS +from .client import TangleApiClient +from .component_generator import regenerate_yaml +from .logger import Logger, get_default_logger +from .models import ComponentInfo, add_official_prefix + + +class HydrationError(RuntimeError): + """Raised when a pipeline cannot be hydrated safely in OSS mode.""" + + +class UnsupportedHydrationFeatureError(HydrationError): + """Raised for TD features intentionally excluded from the OSS CLI.""" + + +@dataclass(frozen=True) +class HydratedPipeline: + """Result returned by :meth:`PipelineHydrator.hydrate_file`.""" + + data: dict[str, Any] + content: str + resolved_count: int + + +ComponentResolver = Callable[ + ["PipelineHydrator", Any, str, Path | None], + tuple[str, dict[str, Any]] | None, +] +COMPONENT_RESOLVERS: dict[str, ComponentResolver] = {} + + +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 _available_resolvers_text(resolvers: Mapping[str, ComponentResolver]) -> 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``. + """ + + 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: + """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, + ) -> None: + self.client = client + 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.log = logger or get_default_logger() + self.component_resolvers: dict[str, ComponentResolver] = dict(COMPONENT_RESOLVERS) + if component_resolvers: + self.component_resolvers.update(component_resolvers) + self.resolution_overrides: dict[str, Any] = resolution_overrides or {} + self._resolution_overrides_str: dict[str, str] = { + k: str(v) for k, v in self.resolution_overrides.items() + } + + def _api_client(self) -> TangleApiClient: + if self.client is None: + from . import client as client_module + + self.client = client_module.TangleApiClient( + timeout=DEFAULT_TIMEOUT_SECONDS, + **self._client_options, + ) + return self.client + + def _cache_key(self, ref_type: str, ref_value: str) -> str: + """Compute a cache key for a component reference.""" + return f"{ref_type}:{ref_value}" + + 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) + return resolver(self, value, path, base_dir) + + 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 + spec = client.get_component_spec(current_digest).data + 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, copy.deepcopy(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})") + return self.fetch_component(digest) + + 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]]: + spec = client.get_component_spec(digest) + if not spec: + raise HydrationError(f"Component not found: {digest}") + return digest, copy.deepcopy(spec.data) + + 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 = url.split("://", 1)[0] if "://" in url else "url" + return self._resolve_registered_component(scheme, url, path, base_dir) + + 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 load_gcs_uri( + self, + url: str, + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + """Overridable hook for downstream GCS support; unsupported in OSS.""" + + raise self._unsupported_resolver("gs") + + 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"} + self.log.info(f" 🔧 Rendering template: {template_path}") + rendered = render_template(full_template_path, context, overrides=overrides) + 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 _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") + + if "template_file" in spec: + result = self._render_template_config(path_obj, spec) + if result is None: + return None + yaml_text, spec = result + + # 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) + + 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})") + if file_path.startswith("gs://"): + raise UnsupportedHydrationFeatureError( + "gs:// resolve configs are not supported by OSS hydrate" + ) + + 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" ⚠️ Resolve config not found: {path_obj}") + return None + + try: + text = path_obj.read_text(encoding="utf-8") + 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 {path_obj}: unset variable {exc}") + return None + except Exception as exc: + raise HydrationError(f"Error parsing resolve config {path_obj}: {exc}") from exc + + 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 {path_obj}" + ) + 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, path_obj.parent) + + 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. + + Ported from TD, with resolver dispatch made registry-backed. + """ + primary = self._resolve_primary(entry, path, base_dir) + local_result = self._resolve_local_side(entry, path, base_dir) + + if not primary and not local_result: + return None + if not primary: + self.log.info(" Resolve: primary source failed, using local source") + return local_result + if not local_result: + return primary + 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", "name"): + 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 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 _resolve_local_side( + self, + entry: dict[str, Any], + path: str, + base_dir: Path | None = None, + ) -> tuple[str, dict[str, Any]] | None: + for kind in self._resolve_entry_kinds(): + if kind not in entry or kind in {"digest", "url", "name"}: + continue + value = entry[kind] + if value: + return self._resolve_registered_component(kind, value, path, base_dir) + return None + + @staticmethod + def _resolve_entry_kinds() -> tuple[str, ...]: + return ( + "digest", + "url", + "name", + "local", + "local_from_python", + "local_from_docker", + "local_from_container", + "from_docker", + "from_container", + "from-docker", + "from-container", + ) + + 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 = self._api_client().get_component_spec(candidate.digest).data + except Exception: + continue + annotations = spec.get("metadata", {}).get("annotations", {}) + 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(): + 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 + + 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") + success = regenerate_yaml( + 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)), + verbose=False, + mode=str(gen_config.get("mode", "inline")), + resolve_root=_resolve_path(gen_config.get("resolve_root")), + ) + 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, + ) -> dict[str, Any]: + """Resolve component references to full componentRef with spec.""" + 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) + 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) + 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) + + pipeline_name = data.get("name", "pipeline") + return utils.traverse_pipeline_tasks(data, pipeline_name, process_task, base_dir) + + @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.""" + input_path = input_file if isinstance(input_file, Path) else Path(str(input_file)) + base_dir = input_path.parent.resolve() + try: + config = yaml.safe_load(input_path.read_text(encoding="utf-8")) + except Exception as exc: + raise HydrationError(f"Failed to read pipeline YAML {input_path}: {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: + 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 + 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_path = Path(output_file) + 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) + + +# ============================================================================= +# 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 _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) 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..6ea81ed --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -0,0 +1,568 @@ +"""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 Shopify-only + 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(name) for name in tasks.keys()) + 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(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, +) -> 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, + resolution_overrides=dict(overrides or {}), + ) + 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 = [str(name) for name in tasks.keys()] + 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 = set(str(name) for name in tasks.keys()) + + for task_id, task_spec in tasks.items(): + if not isinstance(task_spec, Mapping): + continue + target = str(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..2d57acc --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py @@ -0,0 +1,234 @@ +"""`tangle sdk pipelines` local pipeline commands.""" + +from __future__ import annotations + +import pathlib +from typing import Annotated + +from cyclopts import App, Parameter + +from .args_container import ArgsContainer, ConfigFileError +from .pipelines import ( + PipelineValidationError, + generate_mermaid, + hydrate_pipeline_file, + layout_pipeline_file, + validate_pipeline_file, +) + +BaseUrlOption = Annotated[ + str | None, + Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), +] +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.", + negative_iterable=(), + ), +] +ConfigOption = Annotated[ + str | None, + Parameter(help="YAML/JSON config file providing command defaults."), +] + +app = App( + name="pipelines", + help="Validate and visualize local Tangle pipeline specs.", +) + + +@app.command(name="validate") +def pipelines_validate(pipeline_path: pathlib.Path) -> None: + """Validate a local pipeline YAML file.""" + + try: + validate_pipeline_file(pipeline_path) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + print(f"Valid pipeline: {pipeline_path}") + + +@app.command(name="diagram") +def pipelines_diagram(pipeline_path: pathlib.Path) -> None: + """Print a Mermaid dependency diagram for a local pipeline YAML file.""" + + try: + pipeline = validate_pipeline_file(pipeline_path) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + print(generate_mermaid(pipeline)) + + +def _load_config(config: str | None) -> dict[str, object]: + 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 _config_value(config: dict[str, object], key: str) -> object | None: + return config.get(key) if key in config else None + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _optional_path(value: pathlib.Path | str | object | None) -> pathlib.Path | None: + if isinstance(value, pathlib.Path): + return value + if isinstance(value, str): + return pathlib.Path(value) + return 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_value(config, "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 _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, + config: ConfigOption = None, +) -> None: + """Hydrate a local pipeline YAML file.""" + + config_values = _load_config(config) + config_base_url = _optional_str(_config_value(config_values, "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_value(config_values, "var") + resolved_token = token + if resolved_token is None: + resolved_token = _optional_str(_config_value(config_values, "token")) + + try: + result = hydrate_pipeline_file( + pipeline_path, + output=output or _optional_path(_config_value(config_values, "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_value(config_values, "auth_header")) + ), + header=_header_entries(header, config_values), + include_env_credentials=include_env_credentials, + ) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + + 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, +) -> None: + """Add or update editor.position annotations in a local pipeline YAML file.""" + + 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 + 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/pyproject.toml b/pyproject.toml index 8a55147..42d8edc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "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", diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py new file mode 100644 index 0000000..a2ef80d --- /dev/null +++ b/tests/test_pipelines_cli.py @@ -0,0 +1,525 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from tangle_cli import cli + + +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 "components" in output + assert "pipelines" in output + assert "published-components" 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_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_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_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_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_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 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_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_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/uv.lock b/uv.lock index 68f1fd3..ba31214 100644 --- a/uv.lock +++ b/uv.lock @@ -1798,6 +1798,7 @@ dependencies = [ { name = "cyclopts" }, { name = "docstring-parser" }, { name = "httpx" }, + { name = "jinja2" }, { name = "platformdirs" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -1836,6 +1837,7 @@ requires-dist = [ { 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" }, From b6560c85c94669cfd5233c08a6c6e61ceb037551 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 14:21:07 -0700 Subject: [PATCH 042/111] fix: apply resolve config name filters --- .../src/tangle_cli/pipeline_hydrator.py | 4 +- tests/test_pipelines_cli.py | 64 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index d1cdf0a..1421b81 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -496,7 +496,7 @@ def _resolve_primary( 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", "name"): + for kind in ("digest", "url"): if kind not in entry: continue value = entry[kind] @@ -505,6 +505,8 @@ def _resolve_primary( 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( diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index a2ef80d..13957f1 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -439,6 +439,70 @@ def get_component_spec(self, digest): 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 From b94e436da324f0bedd77436eb6d7b0dd34e6259c Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 14:25:14 -0700 Subject: [PATCH 043/111] fix: lazy-load pipeline hydrator API client --- .../src/tangle_cli/pipeline_hydrator.py | 9 ++- tests/test_pipelines_cli.py | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 1421b81..03d58f5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -17,17 +17,20 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import yaml from jinja2 import Environment, FileSystemLoader from . import utils from .api_transport import DEFAULT_TIMEOUT_SECONDS -from .client import TangleApiClient from .component_generator import regenerate_yaml from .logger import Logger, get_default_logger -from .models import ComponentInfo, add_official_prefix +from .utils import add_official_prefix + +if TYPE_CHECKING: + from .client import TangleApiClient + from .models import ComponentInfo class HydrationError(RuntimeError): diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 13957f1..8c1c487 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -1,4 +1,7 @@ import json +import subprocess +import sys +import textwrap from pathlib import Path from types import SimpleNamespace @@ -227,6 +230,59 @@ def test_pipelines_hydrate_writes_output_when_requested(tmp_path: Path, capsys): 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, From c3e3ce3fa967b0af540a40a3d5885c689feb13c9 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 15:22:49 -0700 Subject: [PATCH 044/111] feat: port component publisher behavior --- README.md | 33 +- .../src/tangle_cli/component_publisher.py | 812 ++++++++++++++---- .../tangle_cli/published_components_cli.py | 78 +- tests/test_component_publisher.py | 304 +++++++ tests/test_components_cli.py | 159 ++-- tests/test_tangle_deploy_compat_imports.py | 12 + 6 files changed, 1134 insertions(+), 264 deletions(-) create mode 100644 tests/test_component_publisher.py diff --git a/README.md b/README.md index 4049d40..3e77a4a 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,20 @@ uv run tangle sdk published-components deprecate sha256:old --superseded-by sha2 ``` `publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), -`--dry-run`, generic git metadata fields, generic API auth fields (`--base-url`, -`--token`, `--auth-header`, `-H/--header`), and `--config`. `deprecate` accepts -`--superseded-by`, the same generic API auth fields, and `--config`. These are -single-component OSS commands; batch `publish-all`, dbt generation, -from-container generation, and backend-specific search-v2 workflows remain out -of this lab CLI slice. +`--dry-run`, `--published-by`, generic git metadata fields, generic API auth +fields (`--base-url`, `--token`, `--auth-header`, `-H/--header`), 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. `deprecate` accepts `--superseded-by`, the +same generic API auth fields, and `--config`. + +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. Batch `publish-all`, Slack notification flags, +dbt generation, from-container generation, and backend-specific search-v2 +workflows remain out of this lab CLI slice. Example publish config: @@ -82,6 +90,19 @@ annotations: base_url: https://api.example ``` +Example multi-component publish config: + +```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 +``` + Example deprecate config: ```yaml diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index 8a4d8a6..b9bc9e9 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -1,11 +1,21 @@ -"""Publish and deprecate local Tangle component definitions.""" +"""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. Shopify-specific auth wrappers, Slack +notification plumbing, and a separate ``publish-all`` CLI are kept downstream. +""" from __future__ import annotations -from collections.abc import Mapping +import os +from collections.abc import Mapping, Sequence from dataclasses import dataclass +from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol + +import tangle_cli.utils as utils from .logger import Logger, get_default_logger @@ -13,48 +23,630 @@ from tangle_api.generated.models import ComponentSpec -@dataclass -class ComponentPublishResult: - """Structured result for publishing one component.""" +class ProcessingOutcome(str, Enum): + """Outcome of processing one component publish operation.""" + + SKIP = "skip" + PROCEED = "proceed" + SUCCESS = "success" + ERROR = "error" - status: str - component_path: str - name: str | None = None + +@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 - dry_run: bool = False response: Any = None - error: str | None = None def to_dict(self) -> dict[str, Any]: - return { - "status": self.status, - "component_path": self.component_path, - "name": self.name, + 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, - "dry_run": self.dry_run, "response": _to_plain(self.response), - "error": self.error, } + 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 -class ComponentDeprecationResult: - """Structured result for deprecating one published component.""" +class ComponentPublishHook(Protocol): + """Extension hook for downstream publishers. - status: str - digest: str - superseded_by: str | None = None - response: Any = None - error: str | None = None + Downstream packages can implement one or more methods to observe publish + batches (for example, to send Slack summaries) without OSS importing or + knowing about those systems. + """ + + def before_batch(self, components_config: Sequence[Mapping[str, Any]]) -> None: ... + + def after_component(self, component_path: str, result: ProcessingResult) -> None: ... + + def after_batch(self, results: Sequence[tuple[str, ProcessingResult]]) -> None: ... + + +# ============================================================================ +# Tangle API Functions +# ============================================================================ + + +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. + + ``existing_components`` must already be owner-scoped by the caller. This + function refuses to operate without a client and skips the newly published + digest to avoid self-deprecation. + """ + + log = logger or get_default_logger() + + if not existing_components: + return 0 + if not client: + log.warn(" ⚠️ Cannot deprecate components without TangleApiClient") + return 0 + + log.info(f" Deprecating {len(existing_components)} previous version(s)...") + deprecation_count = 0 + + for old_component in existing_components: + old_digest = _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 + log.info(f" ✅ Successfully deprecated component {old_digest[:16]}...") + else: + log.warn(f" ⚠️ No response from deprecation request for component {old_digest[:16]}...") + except Exception as exc: + log.warn(f" ⚠️ Warning: Failed to deprecate component {old_digest[:16]}...: {exc}") + + if deprecation_count > 0: + log.info(f" ✅ Deprecated {deprecation_count} old version(s)") + + return deprecation_count + + +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. + + 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. + """ + + log = logger or get_default_logger() + verbose = utils.tangle_verbose_enabled() + local_version = spec.version + if verbose: + log.info(f" Local version: {local_version}") + + latest_version = None + + if dry_run: + test_version = os.environ.get("TEST_LATEST_VERSION") + if test_version: + latest_version = test_version + log.info(f" Remote version (test): {latest_version}") + else: + 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 = published_by or _current_user_id(client) + if not filter_by: + 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 = _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: + log.warn(f" Warning: Failed to get version for component {digest[:16]}: {exc}") + continue + + if verbose: + if latest_version: + log.info(f" Remote version: {latest_version}") + else: + 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 "" + 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, + ) + + if verbose: + 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)", + ) + + +# ============================================================================ +# Publisher +# ============================================================================ + + +class ComponentPublisher: + """Publisher for Tangle components.""" + + 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_root: str | Path | None = None, + published_by: str | None = None, + client: Any = None, + hooks: Sequence[ComponentPublishHook] | None = None, + logger: Logger | None = None, + ) -> None: + """Initialize the ComponentPublisher. + + Args mirror the generic ``tangle-deploy`` publisher shape, with + Shopify/Slack-specific fields intentionally omitted. + """ + + self.dry_run = dry_run + self._client = client + self.published_by = published_by + self.hooks = list(hooks or []) + self.log = logger or get_default_logger() + 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") + + def _get_client(self) -> Any | None: + """Get or create a TangleApiClient instance.""" + + if self._client is None and not self.dry_run: + 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 component publishing. " + "Install tangle-cli[native] or provide a local tangle_api.generated package." + ) + return None + raise + self._client = TangleApiClient(logger=self.log) + return self._client + + 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 = path.read_text(encoding="utf-8") + 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 = _component_spec_from_yaml(local_yaml_content, annotations=annotations) + 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 = perform_version_check( + spec=spec, + dry_run=self.dry_run, + client=client, + logger=self.log, + published_by=self.published_by, + ) + + 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(self._git_root)) + 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 _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]}...)") + deprecate_old_components(existing_components, str(new_digest), client=client, logger=self.log) + 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) + + self._run_hook("before_batch", components_config) + 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) + 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) + + 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) + + 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 _run_hook(self, method_name: str, *args: Any) -> None: + for hook in self.hooks: + method = getattr(hook, method_name, None) + if method: + method(*args) + + +# ============================================================================ +# Convenience wrapper functions +# ============================================================================ + + +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, + image: str | None = None, + name: str | None = None, + description: str | None = None, + annotations: dict[str, str] | None = None, + client: Any = None, + published_by: str | None = None, +) -> ProcessingResult: + """Publish one component using ``ComponentPublisher.publish_component``.""" + + publisher = ComponentPublisher( + dry_run=dry_run, + git_remote_sha=git_remote_sha, + git_remote_branch=git_remote_branch, + git_remote_url=git_remote_url, + client=client, + 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), + published_by=kwargs.pop("published_by", None), + client=client, + logger=kwargs.pop("logger", None), + ) + return publisher.publish_component(component_path, **kwargs) - def to_dict(self) -> dict[str, Any]: - return { - "status": self.status, - "digest": self.digest, - "superseded_by": self.superseded_by, - "response": _to_plain(self.response), - "error": self.error, - } + +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 load_component_spec( @@ -64,11 +656,8 @@ def load_component_spec( ) -> "ComponentSpec": """Load a component YAML file into the generated ``ComponentSpec`` model.""" - from tangle_api.generated.models import ComponentSpec - - path = Path(component_path) - text = path.read_text(encoding="utf-8") - return ComponentSpec.from_yaml(text, annotations=dict(annotations or {})) + text = Path(component_path).read_text(encoding="utf-8") + return _component_spec_from_yaml(text, annotations=annotations) def prepare_component_for_publish( @@ -93,123 +682,29 @@ def prepare_component_for_publish( if description: spec.description = description spec.data["description"] = description - - component_yaml_path = _component_yaml_path(path, git_root) spec.update_fields( git_remote_sha=git_remote_sha, git_remote_branch=git_remote_branch, git_remote_url=git_remote_url, image=image, - component_yaml_path=component_yaml_path, + component_yaml_path=_component_yaml_path(path, git_root), ) return spec -def publish_component( - client: Any, - component_path: str | Path, - *, - image: str | None = None, - name: str | None = None, - description: str | None = None, - annotations: Mapping[str, str] | None = None, - dry_run: bool = False, - git_remote_sha: str | None = None, - git_remote_branch: str | None = None, - git_remote_url: str | None = None, - git_root: str | Path | None = None, - logger: Logger | None = None, -) -> ComponentPublishResult: - """Publish one component YAML file using a static Tangle API client.""" - - log = logger or get_default_logger() - path = Path(component_path) - try: - spec = prepare_component_for_publish( - path, - image=image, - name=name, - description=description, - annotations=annotations, - git_remote_sha=git_remote_sha, - git_remote_branch=git_remote_branch, - git_remote_url=git_remote_url, - git_root=git_root, - ) - except Exception as exc: - log.error(f"❌ Failed to load component {path}: {exc}") - return ComponentPublishResult( - status="failed", - component_path=str(path), - dry_run=dry_run, - error=str(exc), - ) - - yaml_text = spec.to_yaml() - if dry_run: - log.info(f"[DRY-RUN] Would publish component: {spec.name}") - return ComponentPublishResult( - status="dry_run", - component_path=str(path), - name=spec.name, - dry_run=True, - response={"name": spec.name, "text": yaml_text}, - ) - - try: - response = client.published_components_create(name=spec.name, text=yaml_text) - digest = _extract_digest(response) - log.info(f"✅ Published component {spec.name}" + (f" ({digest[:16]}...)" if digest else "")) - return ComponentPublishResult( - status="success", - component_path=str(path), - name=spec.name, - digest=digest, - response=response, - ) - except Exception as exc: - log.error(f"❌ Failed to publish component {spec.name}: {exc}") - return ComponentPublishResult( - status="failed", - component_path=str(path), - name=spec.name, - error=str(exc), - ) +# ============================================================================ +# Internal helpers +# ============================================================================ -def deprecate_component( - client: Any, - digest: str, +def _component_spec_from_yaml( + yaml_content: str, *, - superseded_by: str | None = None, - logger: Logger | None = None, -) -> ComponentDeprecationResult: - """Mark a published component as deprecated by digest.""" + annotations: Mapping[str, str] | None = None, +) -> "ComponentSpec": + from tangle_api.generated.models import ComponentSpec - log = logger or get_default_logger() - try: - response = client.published_components_update( - digest=digest, - deprecated=True, - superseded_by=superseded_by, - ) - log.info(f"✅ Deprecated component {digest[:16]}...") - if superseded_by: - log.info(f" Superseded by: {superseded_by[:16]}...") - return ComponentDeprecationResult( - status="success", - digest=digest, - superseded_by=superseded_by, - response=response, - ) - except Exception as exc: - log.error(f"❌ Failed to deprecate component {digest[:16]}...: {exc}") - return ComponentDeprecationResult( - status="failed", - digest=digest, - superseded_by=superseded_by, - error=str(exc), - ) + return ComponentSpec.from_yaml(yaml_content, annotations=dict(annotations or {})) def _component_yaml_path(component_path: Path, git_root: str | Path | None) -> str | None: @@ -221,12 +716,26 @@ def _component_yaml_path(component_path: Path, git_root: str | Path | None) -> s return None -def _extract_digest(response: Any) -> str | None: - plain = _to_plain(response) - if isinstance(plain, Mapping): - digest = plain.get("digest") +def _component_digest(component: Any) -> str | None: + if isinstance(component, Mapping): + digest = component.get("digest") return str(digest) if digest else None - return None + digest = getattr(component, "digest", None) + return str(digest) if digest else None + + +def _current_user_id(client: Any) -> str | None: + 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 _to_plain(value: Any) -> Any: @@ -242,10 +751,15 @@ def _to_plain(value: Any) -> Any: __all__ = [ - "ComponentDeprecationResult", - "ComponentPublishResult", + "ComponentPublishHook", + "ComponentPublisher", + "ProcessingOutcome", + "ProcessingResult", "deprecate_component", + "deprecate_old_components", "load_component_spec", + "perform_version_check", "prepare_component_for_publish", "publish_component", + "publish_component_to_tangle", ] diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 050d455..131d954 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -130,10 +130,10 @@ def get_standard_library(*args: Any, **kwargs: Any) -> Any: return _get_standard_library(*args, **kwargs) -def publish_component(*args: Any, **kwargs: Any) -> Any: - from .component_publisher import publish_component as _publish_component +def ComponentPublisher(*args: Any, **kwargs: Any) -> Any: # noqa: N802 - class-shaped lazy factory + from .component_publisher import ComponentPublisher as _ComponentPublisher - return _publish_component(*args, **kwargs) + return _ComponentPublisher(*args, **kwargs) def deprecate_component(*args: Any, **kwargs: Any) -> Any: @@ -294,6 +294,7 @@ def published_components_publish( 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, @@ -302,7 +303,7 @@ def published_components_publish( ) -> None: """Publish one component YAML file to a Tangle component registry.""" - for args in _load_args( + all_args = _load_args( config, component_path=("component_path", component_path, None, False, True, _optional_path), image=(image, None), @@ -314,36 +315,55 @@ def published_components_publish( 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), **_api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, header=header, ), - ): - 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, - ) - result = publish_component( - client, - args.component_path, - image=args.image, - name=args.name, - description=args.description, - annotations=args.annotations, - 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, - ) - result_dict = result.to_dict() if hasattr(result, "to_dict") else result - _print_json(result_dict) - if isinstance(result_dict, dict) and result_dict.get("status") == "failed": - raise SystemExit(1) + ) + first_args = all_args[0] + client = None if first_args.dry_run else _client_from_options( + base_url=first_args.base_url, + token=first_args.token, + auth_header=first_args.auth_header, + header=first_args.header, + ) + publisher = ComponentPublisher( + dry_run=bool(first_args.dry_run), + git_remote_sha=first_args.git_remote_sha, + git_remote_branch=first_args.git_remote_branch, + git_remote_url=first_args.git_remote_url, + git_root=first_args.git_root, + published_by=first_args.published_by, + client=client, + ) + component_configs = [ + { + "component_path": args.component_path, + "image": args.image, + "name": args.name, + "description": args.description, + "annotations": args.annotations, + } + for args in all_args + ] + exit_code = publisher.publish_components(component_configs) + results = [ + {"component_path": path, **result.to_dict()} + for path, result in getattr(publisher, "results", []) + ] + error_count = sum(1 for result in results if result.get("status") == "error") + summary = { + "status": "failed" if exit_code else "success", + "components_count": len(results), + "error_count": error_count, + "results": results, + } + _print_json(summary) + if exit_code != 0: + raise SystemExit(exit_code) @app.command(name="deprecate") @@ -383,5 +403,5 @@ def published_components_deprecate( ) result_dict = result.to_dict() if hasattr(result, "to_dict") else result _print_json(result_dict) - if isinstance(result_dict, dict) and result_dict.get("status") == "failed": + if isinstance(result_dict, dict) and not result_dict.get("success", result_dict.get("status") != "failed"): raise SystemExit(1) diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py new file mode 100644 index 0000000..e9733cd --- /dev/null +++ b/tests/test_component_publisher.py @@ -0,0 +1,304 @@ +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 ( + ComponentPublisher, + ProcessingOutcome, + deprecate_component, + deprecate_old_components, + perform_version_check, + publish_component_to_tangle, +) + + +@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_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_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))) + + +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 index 02781be..19dd43c 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -6,7 +6,7 @@ import yaml from tangle_cli import cli, published_components_cli -from tangle_cli.component_publisher import deprecate_component, publish_component +from tangle_cli.component_publisher import ProcessingOutcome, ProcessingResult def run_app(app, args: list[str]) -> None: @@ -32,70 +32,26 @@ def _write_component(path: Path, *, name: str = "demo", version: str = "1.0") -> return path -class FakeClient: - def __init__(self) -> None: - self.published_calls: list[dict[str, Any]] = [] - self.deprecate_calls: list[dict[str, Any]] = [] +class FakePublisher: + instances: list["FakePublisher"] = [] - def published_components_create(self, **kwargs: Any) -> dict[str, Any]: - self.published_calls.append(kwargs) - return {"digest": "sha256:abc123", "name": kwargs.get("name")} + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.publish_configs: list[dict[str, Any]] = [] + self.results: list[tuple[str, ProcessingResult]] = [] + FakePublisher.instances.append(self) - def published_components_update(self, **kwargs: Any) -> dict[str, Any]: - self.deprecate_calls.append(kwargs) - return {"digest": kwargs.get("digest"), "deprecated": kwargs.get("deprecated")} - - -def test_publish_component_calls_generated_create_with_loaded_spec(tmp_path: Path): - component_path = _write_component(tmp_path / "component.yaml", name="Original") - client = FakeClient() - - result = publish_component( - client, - component_path, - image="python:3.13", - name="Published Name", - description="Published description", - annotations={"owner": "oss"}, - ) - - assert result.status == "success" - assert result.digest == "sha256:abc123" - assert client.published_calls == [ - { - "name": "Published Name", - "text": client.published_calls[0]["text"], - } - ] - payload = yaml.safe_load(client.published_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"] - - -def test_publish_component_dry_run_does_not_call_api(tmp_path: Path): - component_path = _write_component(tmp_path / "component.yaml", name="Dry Run") - client = FakeClient() - - result = publish_component(client, component_path, dry_run=True) - - assert result.status == "dry_run" - assert result.dry_run is True - assert client.published_calls == [] - assert result.response["name"] == "Dry Run" - - -def test_deprecate_component_calls_generated_update(): - client = FakeClient() - - result = deprecate_component(client, "sha256:old", superseded_by="sha256:new") - - assert result.status == "success" - assert client.deprecate_calls == [ - {"digest": "sha256:old", "deprecated": True, "superseded_by": "sha256:new"} - ] + def publish_components(self, component_configs: list[dict[str, Any]]) -> int: + self.publish_configs = component_configs + for config in component_configs: + result = ProcessingResult( + outcome=ProcessingOutcome.SUCCESS, + local_version="1.0", + latest_version=None, + reason=f"Dry-run: would publish {config.get('name') or 'component'}", + ) + self.results.append((str(config["component_path"]), result)) + return 0 def test_published_components_publish_cli_wiring_and_config_precedence(monkeypatch, tmp_path: Path, capsys): @@ -109,17 +65,13 @@ def test_published_components_publish_cli_wiring_and_config_precedence(monkeypat " from_config: yes\n", encoding="utf-8", ) - calls: list[dict[str, Any]] = [] def fake_client_from_options(**kwargs: Any) -> object: raise AssertionError("dry-run publish must not create an API client") - def fake_publish_component(client: object, component_path: Path, **kwargs: Any) -> dict[str, Any]: - calls.append({"client": client, "component_path": component_path, **kwargs}) - return {"status": "dry_run", "name": kwargs["name"], "dry_run": kwargs["dry_run"]} - + FakePublisher.instances = [] monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) - monkeypatch.setattr(published_components_cli, "publish_component", fake_publish_component) + monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() run_app( @@ -128,25 +80,71 @@ def fake_publish_component(client: object, component_path: Path, **kwargs: Any) ) result = json.loads(capsys.readouterr().out) - assert result["status"] == "dry_run" - assert result["name"] == "CLI Name" - assert calls == [ + 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, + } + assert FakePublisher.instances[0].publish_configs == [ { - "client": None, "component_path": component_path, "image": None, "name": "CLI Name", "description": None, "annotations": {"from_config": True}, - "dry_run": True, - "git_remote_sha": None, - "git_remote_branch": None, - "git_remote_url": None, - "git_root": None, } ] +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 FakePublisher.instances[0].publish_configs == [ + { + "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_deprecate_cli_wiring_and_config(monkeypatch, tmp_path: Path, capsys): config = tmp_path / "deprecate.yaml" config.write_text( @@ -167,7 +165,7 @@ def fake_client_from_options(**kwargs: Any) -> object: def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict[str, Any]: deprecate_calls.append({"client": client, "digest": digest, **kwargs}) - return {"status": "success", "digest": digest, "superseded_by": kwargs.get("superseded_by")} + 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) @@ -178,7 +176,7 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict result = json.loads(capsys.readouterr().out) assert result == { "digest": "sha256:from-config", - "status": "success", + "success": True, "superseded_by": "sha256:new", } assert client_calls == [ @@ -219,6 +217,7 @@ def test_components_and_published_components_help_reflect_api_split(capsys): publish_help = capsys.readouterr().out assert "base-url" in publish_help assert "auth-header" in publish_help + assert "published-by" in publish_help assert "slack" not in publish_help.lower() assert "shopify" not in publish_help.lower() assert "publish-all" not in publish_help diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 9053c50..97069b8 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -14,9 +14,15 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: from tangle_cli.component_from_func import generate_component_yaml from tangle_cli.component_generator import regenerate_yaml from tangle_cli.component_publisher import ( + ComponentPublisher, + ProcessingOutcome, + ProcessingResult, deprecate_component, + deprecate_old_components, + perform_version_check, prepare_component_for_publish, publish_component, + publish_component_to_tangle, ) from tangle_cli.module_bundler import ModuleBundler from tangle_cli.version_manager import bump_version @@ -106,8 +112,14 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert ArgsContainer and ConfigFileError assert callable(generate_component_yaml) assert callable(regenerate_yaml) + assert ComponentPublisher 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 From c76ec359bc1637300dd1ff1362c7d8ea06acad12 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 15:31:47 -0700 Subject: [PATCH 045/111] fix: isolate published component config credentials --- .../tangle_cli/published_components_cli.py | 78 ++++--- tests/test_api_cli.py | 15 +- tests/test_components_cli.py | 204 +++++++++++++++--- 3 files changed, 234 insertions(+), 63 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 131d954..ccf18ba 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -53,6 +53,7 @@ def _client_from_options( token: str | None = None, auth_header: str | None = None, header: list[str] | None = None, + include_env_credentials: bool = True, ) -> Any: """Create the static client used by published-component commands.""" @@ -73,6 +74,7 @@ def _client_from_options( auth_header=auth_header, header=header, timeout=DEFAULT_TIMEOUT_SECONDS, + include_env_credentials=include_env_credentials, ) @@ -91,6 +93,11 @@ def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: return pathlib.Path(value) if value is not None else None +def _include_env_credentials(args: ArgsContainer, cli_base_url: str | None) -> bool: + config_base_url = getattr(args, "_config", {}).get("base_url") + return not (cli_base_url is None and config_base_url is not None) + + def _api_arg_specs( *, base_url: str | None = None, @@ -175,6 +182,7 @@ def published_components_search( token=args.token, auth_header=args.auth_header, header=args.header, + include_env_credentials=_include_env_credentials(args, base_url), ) _print_json( search_components( @@ -229,6 +237,7 @@ def published_components_inspect( token=args.token, auth_header=args.auth_header, header=args.header, + include_env_credentials=_include_env_credentials(args, base_url), ) if args.digest: result = inspect_by_digest( @@ -274,6 +283,7 @@ def published_components_library( token=args.token, auth_header=args.auth_header, header=args.header, + include_env_credentials=_include_env_credentials(args, base_url), ) _print_json(get_standard_library(client)) @@ -323,47 +333,44 @@ def published_components_publish( header=header, ), ) - first_args = all_args[0] - client = None if first_args.dry_run else _client_from_options( - base_url=first_args.base_url, - token=first_args.token, - auth_header=first_args.auth_header, - header=first_args.header, - ) - publisher = ComponentPublisher( - dry_run=bool(first_args.dry_run), - git_remote_sha=first_args.git_remote_sha, - git_remote_branch=first_args.git_remote_branch, - git_remote_url=first_args.git_remote_url, - git_root=first_args.git_root, - published_by=first_args.published_by, - client=client, - ) - component_configs = [ - { - "component_path": args.component_path, - "image": args.image, - "name": args.name, - "description": args.description, - "annotations": args.annotations, - } - for args in all_args - ] - exit_code = publisher.publish_components(component_configs) - results = [ - {"component_path": path, **result.to_dict()} - for path, result in getattr(publisher, "results", []) - ] - error_count = sum(1 for result in results if result.get("status") == "error") + results: list[dict[str, Any]] = [] + for args in all_args: + 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(args, base_url), + ) + 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, + ) + 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}) + + error_count = sum(1 for result in results if result.get("status") in {"error", "failed"}) summary = { - "status": "failed" if exit_code else "success", + "status": "failed" if error_count else "success", "components_count": len(results), "error_count": error_count, "results": results, } _print_json(summary) - if exit_code != 0: - raise SystemExit(exit_code) + if error_count: + raise SystemExit(1) @app.command(name="deprecate") @@ -395,6 +402,7 @@ def published_components_deprecate( token=args.token, auth_header=args.auth_header, header=args.header, + include_env_credentials=_include_env_credentials(args, base_url), ) result = deprecate_component( client, diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index decf597..628ba11 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -405,6 +405,8 @@ def test_sdk_published_components_search_uses_config_with_cli_precedence(monkeyp "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", @@ -444,8 +446,13 @@ def fake_client_from_options(**kwargs): "published_by": "config@example.com", "digest": "sha256:cli", } - assert client_calls[-1]["base_url"] == "https://config.example" - assert client_calls[-1]["header"] == ["X-Config: yes"] + 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, + } def test_sdk_published_components_inspect_and_library_use_config(monkeypatch, tmp_path, capsys): @@ -491,6 +498,7 @@ def fake_client_from_options(**kwargs): 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, @@ -499,6 +507,7 @@ def fake_client_from_options(**kwargs): 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_sdk_published_components_client_from_options_uses_static_client(): @@ -509,6 +518,7 @@ def test_sdk_published_components_client_from_options_uses_static_client(): token="token", auth_header="Bearer auth", header=["X-Test: yes"], + include_env_credentials=False, ) assert isinstance(client, TangleApiClient) @@ -516,6 +526,7 @@ def test_sdk_published_components_client_from_options_uses_static_client(): 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(): diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 19dd43c..1eac285 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -37,21 +37,17 @@ class FakePublisher: def __init__(self, **kwargs: Any) -> None: self.kwargs = kwargs - self.publish_configs: list[dict[str, Any]] = [] - self.results: list[tuple[str, ProcessingResult]] = [] + self.publish_calls: list[dict[str, Any]] = [] FakePublisher.instances.append(self) - def publish_components(self, component_configs: list[dict[str, Any]]) -> int: - self.publish_configs = component_configs - for config in component_configs: - result = ProcessingResult( - outcome=ProcessingOutcome.SUCCESS, - local_version="1.0", - latest_version=None, - reason=f"Dry-run: would publish {config.get('name') or 'component'}", - ) - self.results.append((str(config["component_path"]), result)) - return 0 + 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): @@ -91,7 +87,7 @@ def fake_client_from_options(**kwargs: Any) -> object: "published_by": None, "client": None, } - assert FakePublisher.instances[0].publish_configs == [ + assert FakePublisher.instances[0].publish_calls == [ { "component_path": component_path, "image": None, @@ -102,6 +98,83 @@ def fake_client_from_options(**kwargs: Any) -> object: ] +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, + } + ] + 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") @@ -127,22 +200,95 @@ def test_published_components_publish_config_array_is_batch_interface(monkeypatc result = json.loads(capsys.readouterr().out) assert result["status"] == "success" assert result["components_count"] == 2 - assert FakePublisher.instances[0].publish_configs == [ + 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 == [ { - "component_path": first, - "image": "python:3.12", - "name": "One Config", - "description": None, - "annotations": None, + "base_url": "https://first.example", + "token": "first-token", + "auth_header": None, + "header": None, + "include_env_credentials": False, + } + ] + 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, }, { - "component_path": second, - "image": "python:3.12", - "name": "Two Config", - "description": None, - "annotations": None, + "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, }, ] + 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): @@ -180,7 +326,13 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict "superseded_by": "sha256:new", } assert client_calls == [ - {"base_url": "https://api.test", "token": None, "auth_header": None, "header": ["X-Test: yes"]} + { + "base_url": "https://api.test", + "token": None, + "auth_header": None, + "header": ["X-Test: yes"], + "include_env_credentials": False, + } ] assert deprecate_calls == [ {"client": fake_client, "digest": "sha256:from-config", "superseded_by": "sha256:new"} From 0149aa11eb24bf365094fb322c1c8a1590452f32 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 17:21:19 -0700 Subject: [PATCH 046/111] feat: add read-only artifact lookup command --- .../tangle-cli/src/tangle_cli/artifacts.py | 209 ++++++++++++++++++ .../src/tangle_cli/artifacts_cli.py | 184 +++++++++++++++ packages/tangle-cli/src/tangle_cli/cli.py | 2 + tests/test_artifacts.py | 203 +++++++++++++++++ tests/test_artifacts_cli.py | 158 +++++++++++++ tests/test_pipelines_cli.py | 1 + tests/test_tangle_deploy_compat_imports.py | 10 + 7 files changed, 767 insertions(+) create mode 100644 packages/tangle-cli/src/tangle_cli/artifacts.py create mode 100644 packages/tangle-cli/src/tangle_cli/artifacts_cli.py create mode 100644 tests/test_artifacts.py create mode 100644 tests/test_artifacts_cli.py 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..b58b7a0 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -0,0 +1,209 @@ +"""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, is_dataclass +from typing import TYPE_CHECKING, Any, Protocol + +from .models import ArtifactComponentQuery, ArtifactInfo + +if TYPE_CHECKING: + from .client import TangleApiClient + + +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: ... + + +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 _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 _collect_artifacts( + 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. + + Handles both direct task matches and component matches at any nesting level. + Returns a dict mapping ``"path/to/task/output_name"`` to artifact ID. + """ + + 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_filter: list[str] = [] + matched = False + + for query_name in (task_name, key_prefix): + if query_name in tasks_query: + output_filter = tasks_query[query_name] + matched = True + 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_filter = component.outputs if component.outputs else output_filter + matched = True + + out_artifacts = _artifact_id_map(_mapping_or_attr(child_task, "execution_output_artifacts", {})) + if matched and out_artifacts: + for output_name, artifact_id in out_artifacts.items(): + if not output_filter or output_name in output_filter: + 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( + _collect_artifacts( + child_execution, + tasks_query, + components_query, + prefix=f"{key_prefix}/", + ) + ) + + return artifact_ids + + +def _collect_execution_artifacts( + client: ArtifactClient, + execution_ids: dict[str, list[str]], +) -> dict[str, str]: + """Collect artifact IDs directly from execution IDs. + + Fetches each execution's details and extracts output artifacts, avoiding the + full run-details tree walk. + """ + + artifact_ids: dict[str, str] = {} + 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 _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) + + +def get_artifacts( + run_id: str, + query: dict[str, Any], + client: ArtifactClient, +) -> 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(_collect_execution_artifacts(client, executions_query)) + + tasks_query = query.get("tasks", {}) or {} + components_query_raw = query.get("components", []) or [] + if tasks_query or components_query_raw: + details = 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( + _collect_artifacts( + execution, + tasks_query, + _component_queries(components_query_raw), + ) + ) + + artifacts: dict[str, ArtifactInfo] = {} + for key, artifact_id in artifact_ids.items(): + try: + response = 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 + + +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 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..7a6c561 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py @@ -0,0 +1,184 @@ +"""`tangle sdk artifacts` read-only artifact commands.""" + +from __future__ import annotations + +import json +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .api_transport import DEFAULT_TIMEOUT_SECONDS +from .args_container import ArgsContainer, ConfigFileError + +BaseUrlOption = Annotated[ + str | None, + Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), +] +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( + alias="-H", + help="Custom request header as 'Name: value'. Repeat for multiple.", + negative_iterable=(), + ), +] +ConfigOption = Annotated[ + str | None, + Parameter(help="YAML/JSON config file providing command defaults."), +] +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.", +) + + +def _print_json(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: + try: + return ArgsContainer.load(config, **kwargs) + except ConfigFileError as exc: + raise SystemExit(f"Config error: {exc}") from exc + + +def _client_from_options( + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, + include_env_credentials: bool = True, +) -> Any: + """Create the static client used by read-only artifact commands.""" + + try: + from .client import TangleApiClient + except ModuleNotFoundError as exc: + if exc.name == "tangle_api": + raise SystemExit( + "Native generated Tangle API bindings are required for " + "artifact commands. Install tangle-cli[native] or provide " + "a local tangle_api.generated package." + ) from exc + raise + + return TangleApiClient( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + timeout=DEFAULT_TIMEOUT_SECONDS, + include_env_credentials=include_env_credentials, + ) + + +def _include_env_credentials(args: ArgsContainer, cli_base_url: str | None) -> bool: + config_base_url = getattr(args, "_config", {}).get("base_url") + return not (cli_base_url is None and config_base_url is not 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, ...]]: + return { + "base_url": (base_url, None), + "token": (token, None), + "auth_header": (auth_header, None), + "header": (header, None), + } + + +def get_artifacts(*args: Any, **kwargs: Any) -> Any: + from .artifacts import get_artifacts as _get_artifacts + + return _get_artifacts(*args, **kwargs) + + +def _serialize_artifacts(*args: Any, **kwargs: Any) -> Any: + from .artifacts import _serialize_artifacts as _serialize + + return _serialize(*args, **kwargs) + + +@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, +) -> None: + """Get artifact metadata for tasks/components in a pipeline run.""" + + all_args = _load_args( + config, + run_id=("run_id", run_id, None, False, True), + query=("query", query, None, True, True), + **_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: + 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(args, base_url), + ) + try: + artifacts = get_artifacts(args.run_id, args.query, client=client) + 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": _serialize_artifacts(artifacts), + } + ) + + _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 index 643aee6..ed5556f 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -1,6 +1,7 @@ from cyclopts import App from . import api_cli +from . import artifacts_cli from . import components_cli from . import pipelines_cli from . import published_components_cli @@ -13,6 +14,7 @@ def build_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(published_components_cli.app) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..0ab3c75 --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from tangle_cli.artifacts import get_artifacts, _serialize_artifacts + + +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 = get_artifacts( + "run-1", + {"artifact_ids": ["artifact-1", "artifact-2"]}, + client=client, + ) + + 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 = get_artifacts("run-1", {"executions": {"exec-1": ["model"]}}, client=client) + + 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 = get_artifacts("run-1", {"tasks": {"Train": ["model"]}}, client=client) + + 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 = get_artifacts( + "run-1", + { + "components": [ + {"name": "Embed Text", "outputs": ["vectors"]}, + {"digest": "sha256:score"}, + ] + }, + client=client, + ) + + 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_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 = get_artifacts("run-1", {"tasks": {"Subgraph/Inner": ["out"]}}, client=client) + + 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 = get_artifacts("run-1", {"artifact_ids": ["good", "bad"]}, client=client) + + assert artifacts["good"].uri == "gs://bucket/good" + assert artifacts["bad"].id == "bad" + assert artifacts["bad"].uri == "" + assert artifacts["bad"].error == "not found" + + serialized = _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"): + get_artifacts("run-1", {"tasks": {"Train": []}}, client=client) diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py new file mode 100644 index 0000000..a9d34a2 --- /dev/null +++ b/tests/test_artifacts_cli.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import builtins +import importlib +import json +import sys +from typing import Any + +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(run_id: str, query: dict[str, Any], *, client: object) -> dict[str, object]: + get_calls.append({"run_id": run_id, "query": query, "client": client}) + return {"artifact-config": object()} + + monkeypatch.setattr(artifacts_cli, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(artifacts_cli, "get_artifacts", fake_get_artifacts) + monkeypatch.setattr( + artifacts_cli, + "_serialize_artifacts", + 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, + } + ] + 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, "_client_from_options", fake_client_from_options) + monkeypatch.setattr(artifacts_cli, "get_artifacts", lambda *args, **kwargs: {}) + monkeypatch.setattr(artifacts_cli, "_serialize_artifacts", 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_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_pipelines_cli.py b/tests/test_pipelines_cli.py index 8c1c487..6e5a6d4 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -63,6 +63,7 @@ def test_sdk_help_includes_pipelines(capsys): 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 diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 97069b8..b19021a 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -24,6 +24,12 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: publish_component, publish_component_to_tangle, ) + from tangle_cli.artifacts import ( + _collect_artifacts, + _collect_execution_artifacts, + _serialize_artifacts, + get_artifacts, + ) from tangle_cli.module_bundler import ModuleBundler from tangle_cli.version_manager import bump_version from tangle_cli.component_inspector import ( @@ -123,6 +129,10 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert callable(deprecate_component) assert callable(bump_version) assert ModuleBundler is not None + assert callable(_collect_artifacts) + assert callable(_collect_execution_artifacts) + assert callable(_serialize_artifacts) + assert callable(get_artifacts) assert callable(get_standard_library) assert callable(inspect_by_digest) assert callable(inspect_by_name) From e442e4121558c6d2800a5b56f5aaa75d984b57bf Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 17:34:13 -0700 Subject: [PATCH 047/111] feat: add pipeline run commands --- packages/tangle-cli/src/tangle_cli/api_cli.py | 114 ++--- .../src/tangle_cli/api_transport.py | 96 ++++ packages/tangle-cli/src/tangle_cli/cli.py | 2 + .../tangle-cli/src/tangle_cli/cli_helpers.py | 74 +++ .../tangle-cli/src/tangle_cli/cli_options.py | 48 ++ packages/tangle-cli/src/tangle_cli/client.py | 22 +- .../src/tangle_cli/components_cli.py | 35 +- .../src/tangle_cli/pipeline_hydrator.py | 3 +- .../src/tangle_cli/pipeline_runs.py | 434 ++++++++++++++++++ .../src/tangle_cli/pipeline_runs_cli.py | 404 ++++++++++++++++ .../src/tangle_cli/pipelines_cli.py | 76 +-- .../tangle_cli/published_components_cli.py | 170 ++----- tests/test_api_cli.py | 41 +- tests/test_api_transport.py | 95 +++- tests/test_pipeline_runs_cli.py | 339 ++++++++++++++ tests/test_static_client.py | 53 +++ 16 files changed, 1699 insertions(+), 307 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/cli_helpers.py create mode 100644 packages/tangle-cli/src/tangle_cli/cli_options.py create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_runs.py create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py create mode 100644 tests/test_pipeline_runs_cli.py diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index 110ca11..3a496f7 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -56,7 +56,6 @@ _unwrap_nullable_schema, ) from .api_transport import ( - DEFAULT_API_URL, DEFAULT_TIMEOUT_SECONDS, _ambient_auth_env_present, _env_header_entries, @@ -73,41 +72,16 @@ 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 -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( - alias="-H", - help=( - "Custom request header as 'Name: value'. Repeat for multiple. " - "Applied after TANGLE_API_HEADERS." - ), - negative_iterable=(), - ), -] BodyOption = Annotated[ str | None, Parameter(help="JSON request body, or @path/to/file.json."), @@ -123,32 +97,6 @@ ) ), ] -ConfigOption = Annotated[ - str | None, - Parameter(help="YAML/JSON config file providing command defaults."), -] - - -def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: - try: - return ArgsContainer.load(config, **kwargs) - except ConfigFileError as exc: - raise SystemExit(f"Config error: {exc}") from exc - - -def _common_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, ...]]: - return { - "base_url": (base_url, None), - "token": (token, None), - "auth_header": (auth_header, None), - "header": (header, None), - } def build_app(schema: dict[str, Any] | None = None) -> App: @@ -215,9 +163,9 @@ def refresh( ) -> None: """Fetch /openapi.json and update the local schema cache.""" - for args in _load_args( + for args in load_args_or_exit( config, - **_common_api_arg_specs( + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -256,7 +204,7 @@ def _register_reset_cache_command(api_app: App) -> None: 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(config, base_url=(base_url, None)): + 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() ) @@ -455,14 +403,14 @@ def _operation_args_from_config( if operation.has_request_body: specs["body"] = (values.get("body"), None) specs.update( - _common_api_arg_specs( + 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(config, **specs) + resolved = load_args_or_exit(config, **specs) for args in resolved: for parameter in operation.parameters: if parameter.required or parameter.default is None: @@ -559,7 +507,7 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: ) return cached - cache_base_url = _auto_cache_base_url(configured_base_url, first_command, help_requested) + 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() @@ -588,25 +536,37 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: def _auto_cache_base_url( configured_base_url: str | None, - first_command: str | None, help_requested: bool, ) -> str | None: if configured_base_url: return configured_base_url - if not _ambient_auth_env_present(): - return default_base_url() - if help_requested: + if help_requested and _ambient_auth_env_present() and not os.environ.get("TANGLE_API_URL"): return None - if first_command is not None: - # Preserve the actionable transport guard for real generated command - # dispatch while avoiding an implicit localhost probe for help-only - # invocations with ambient credentials. - return default_base_url() - return None + return default_base_url() def _api_tail_requests_help(api_tail: list[str]) -> bool: - return any(arg in {"--help", "-h"} for arg in api_tail) + 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: diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index 2c6071c..e9fdddd 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -5,6 +5,7 @@ import json import os import re +import sys import urllib.parse from pathlib import Path from typing import Any @@ -15,6 +16,90 @@ 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|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential)", re.IGNORECASE) +_REDACTED = "" + + +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(): + redacted[name] = _REDACTED if name.lower() in _SENSITIVE_HEADER_NAMES 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 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: @@ -203,6 +288,17 @@ def request_operation( 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 diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py index 643aee6..85264cd 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -2,6 +2,7 @@ from . import api_cli from . import components_cli +from . import pipeline_runs_cli from . import pipelines_cli from . import published_components_cli @@ -15,6 +16,7 @@ def build_sdk_app() -> 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) return sdk_app 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..74fbbb7 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/cli_helpers.py @@ -0,0 +1,74 @@ +"""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), + } + + +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..672046d --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/cli_options.py @@ -0,0 +1,48 @@ +"""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."), +] diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index d1dc144..b8440b9 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -23,10 +23,12 @@ _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 +from .logger import Logger, _null_logger, get_default_logger from .models import ( ComponentInfo, GraphExecutionState, @@ -65,8 +67,9 @@ def __init__( include_env_credentials: bool = True, ) -> None: self.base_url = _normalize_base_url(base_url or default_base_url()) - self.logger = logger or _null_logger - self.verbose = verbose + 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 @@ -214,8 +217,6 @@ def _request_with_same_origin_redirects( for _ in range(self._MAX_REDIRECTS + 1): request_headers = self._headers(extra_headers) - if self.verbose: - self.logger.info(f"{current_method} {current_url}") response = self.session.request( current_method, current_url, @@ -226,6 +227,17 @@ def _request_with_same_origin_redirects( 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 diff --git a/packages/tangle-cli/src/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py index 1f65f8e..1136a7a 100644 --- a/packages/tangle-cli/src/tangle_cli/components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -4,7 +4,8 @@ from cyclopts import App, Parameter -from .args_container import ArgsContainer, ConfigFileError +from .cli_helpers import load_args_or_exit, optional_path +from .cli_options import ConfigOption app = App(name="components", help="Work with Tangle component definitions.") @@ -19,24 +20,6 @@ annotations_app = App(name="annotations", help="Work with component annotations.") app.command(annotations_app) -ConfigOption = Annotated[ - str | None, - Parameter(help="YAML/JSON config file providing command defaults."), -] - - -def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: - try: - return ArgsContainer.load(config, **kwargs) - except ConfigFileError as exc: - raise SystemExit(f"Config error: {exc}") from exc - - -def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: - return pathlib.Path(value) if value is not None else None - - - # region components @@ -122,18 +105,18 @@ def _components_generate_from_python_impl( resolve_root: pathlib.Path | None = None, config: str | None = None, ) -> None: - all_args = _load_args( + all_args = load_args_or_exit( config, - python_file=("python_file", python_file, None, False, True, _optional_path), - output=(output, None, _optional_path), + 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), + 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), + resolve_root=(resolve_root, None, optional_path), ) for args in all_args: from .component_generator import determine_output_path, regenerate_yaml @@ -247,9 +230,9 @@ def components_bump_version( ) -> None: """Bump version metadata in a component YAML file.""" - all_args = _load_args( + all_args = load_args_or_exit( config, - yaml_file=("yaml_file", yaml_file, None, False, True, _optional_path), + yaml_file=("yaml_file", yaml_file, None, False, True, optional_path), set_version=(set_version, None), update_timestamp=(update_timestamp, None), ) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 03d58f5..85dd8f4 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -20,7 +20,6 @@ from typing import TYPE_CHECKING, Any import yaml -from jinja2 import Environment, FileSystemLoader from . import utils from .api_transport import DEFAULT_TIMEOUT_SECONDS @@ -90,6 +89,8 @@ def render_template( 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( diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py new file mode 100644 index 0000000..97286c5 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -0,0 +1,434 @@ +"""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; Shopify/GCP, +Slack, 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 json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from .api_transport import tangle_verbose_enabled +from .logger import Logger, _null_logger, get_default_logger +from .pipeline_hydrator import PipelineHydrator +from .utils import dump_yaml + +_TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "CANCELLED", "CANCELED", "SYSTEM_ERROR", "SKIPPED"} +_ACTIVE_STATUSES = {"RUNNING", "PENDING", "QUEUED", "CANCELLING", "CANCELING"} + + +class PipelineRunError(RuntimeError): + """Raised when a pipeline-run operation cannot complete.""" + + +class UnsupportedPipelineRunFeatureError(PipelineRunError): + """Raised for TD extension points intentionally unsupported in OSS defaults.""" + + +@dataclass +class PipelineRunHooks: + """Overridable seams for downstream tangle-deploy behavior. + + Subclasses can override these methods to add Shopify auth wrappers, gs:// + loading, JOB_CONFIG time input, run-as annotations, mutex/schedule behavior, + graceful shutdown, Slack notifications, Observe/GCP logs, or from-container + runtime defaults without forking the generic pipeline-run manager. + """ + + logger: Logger = field( + default_factory=lambda: get_default_logger() if tangle_verbose_enabled() else _null_logger + ) + + 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, + *, + client: Any, + resolution_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + hydrator = PipelineHydrator( + client=client, + resolution_overrides=resolution_overrides, + logger=self.logger, + ) + return hydrator.hydrate_file(pipeline_path).data + + 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 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: + """Hook for TD mutex/overlap checks.""" + + def after_submit(self, response: Mapping[str, Any]) -> None: + """Hook for downstream start notifications.""" + + def after_wait(self, result: Mapping[str, Any]) -> None: + """Hook for downstream success/failure notifications.""" + + 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: + client: Any + hooks: PipelineRunHooks = field(default_factory=PipelineRunHooks) + logger: Logger = field(default_factory=get_default_logger) + + @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) + if isinstance(value, dict): + return {key: PipelineRunManager.to_plain(val) for key, val in value.items()} + if isinstance(value, list): + return [PipelineRunManager.to_plain(item) for item in value] + 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 underscore-prefixed annotations such as ``_source_dir`` + while recursively resolving local files. Those are local provenance + only and must not be submitted to the backend. 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 + + cleaned: dict[str, Any] = {} + for key, item in value.items(): + if str(key).startswith("_"): + 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 is_terminal_status(status: str | None) -> bool: + return bool(status and status.upper() in _TERMINAL_STATUSES) + + @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 + + 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: + return self.hooks.hydrate_pipeline( + pipeline_path, + client=self.client, + resolution_overrides=resolution_overrides, + ) + return self.hooks.read_pipeline_yaml(pipeline_path) + + 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]: + pipeline_spec = self.load_pipeline_for_submit( + pipeline_path, + hydrate=hydrate, + resolution_overrides=resolution_overrides, + ) + run_args = self.hooks.prepare_run_arguments(pipeline_spec, run_args) + payload = self.convert_yaml_to_payload(copy.deepcopy(pipeline_spec), run_args) + payload = self.sanitize_submit_payload(payload) + submit_annotations = self.hooks.extra_submit_annotations( + pipeline_spec=pipeline_spec, + pipeline_path=pipeline_path, + run_as=run_as, + ) + if annotations: + submit_annotations.update({str(k): str(v) for k, v in annotations.items()}) + return {"root_task": payload["root_task"], "annotations": submit_annotations} + + 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, + ) -> dict[str, Any]: + body = self.build_submit_body( + pipeline_path, + run_args=run_args, + annotations=annotations, + hydrate=hydrate, + run_as=run_as, + resolution_overrides=resolution_overrides, + ) + pipeline_spec = body["root_task"]["componentRef"]["spec"] + self.hooks.before_submit(pipeline_spec) + response = self.to_plain(self.client.pipeline_runs_create(body=body)) + self.hooks.after_submit(response) + return response + + 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, + ) -> dict[str, Any]: + return self.to_plain( + self.client.get_run_details( + run_id, + include_annotations=include_annotations, + include_execution_state=include_execution_state, + ) + ) + + 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) -> dict[str, Any]: + return self.to_plain(self.client.executions_graph_execution_state(execution_id)) + + 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 annotations_list(self, run_id: str) -> dict[str, Any]: + return self.to_plain(self.client.pipeline_runs_annotations(run_id)) + + def annotations_set(self, run_id: str, key: str, value: Any) -> dict[str, Any]: + self.client.pipeline_runs_put_annotations(run_id, key, value=value) + return {"id": run_id, "key": key, "value": value} + + def annotations_delete(self, run_id: str, key: str) -> dict[str, Any]: + self.client.pipeline_runs_delete_annotations(run_id, key) + return {"id": run_id, "key": key, "deleted": True} + + def export_run(self, run_id: str, output: str | Path | None = None) -> 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): + raise PipelineRunError(f"Pipeline spec for run {run_id} is not exportable") + content = dump_yaml(spec) + if output is None: + return {"run_id": run_id, "pipeline": spec, "yaml": content} + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + return {"run_id": run_id, "output": str(output_path)} + + def wait_for_completion( + self, + run_id: str, + *, + max_wait: float, + poll_interval: float, + ) -> dict[str, Any]: + if max_wait < 0: + raise PipelineRunError("--max-wait must be non-negative") + if poll_interval <= 0: + raise PipelineRunError("--poll-interval must be positive") + deadline = time.monotonic() + max_wait + last_run: dict[str, Any] = {} + while True: + last_run = self.get_run(run_id, include_execution_stats=True) + status = self.status_from_run(last_run) + if self.is_terminal_status(status) or status == "ENDED": + result = {"run": last_run, "status": status or "ENDED", "timed_out": False} + self.hooks.after_wait(result) + return result + if time.monotonic() >= deadline: + return {"run": last_run, "status": status or "UNKNOWN", "timed_out": True} + time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic()))) + + +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 | None, entries: list[str] | None = None) -> dict[str, Any]: + result: dict[str, Any] = {} + if text: + loaded = 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_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py new file mode 100644 index 0000000..8418d44 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -0,0 +1,404 @@ +"""`tangle sdk pipeline-runs` command implementation.""" + +from __future__ import annotations + +import pathlib +from typing import Annotated, Any + +from cyclopts import App, Parameter + +from .api_transport import DEFAULT_TIMEOUT_SECONDS +from .args_container import ArgsContainer +from .cli_helpers import ( + api_arg_specs, + include_env_credentials_for_args, + load_args_or_exit, + optional_path, + print_json, +) +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + TokenOption, +) +from .pipeline_runs import ( + PipelineRunError, + PipelineRunManager, + parse_json_or_key_values, + parse_key_value_entries, +) + +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 _client_from_options( + *, + base_url: str | None = None, + token: str | None = None, + auth_header: str | None = None, + header: list[str] | None = None, + include_env_credentials: bool = True, +) -> Any: + try: + from .client import TangleApiClient + except ModuleNotFoundError as exc: + if exc.name == "tangle_api": + raise SystemExit( + "Native generated Tangle API bindings are required for " + "pipeline-run commands. Install tangle-cli[native] or provide " + "a local tangle_api.generated package." + ) from exc + raise + + return TangleApiClient( + base_url=base_url, + token=token, + auth_header=auth_header, + header=header, + timeout=DEFAULT_TIMEOUT_SECONDS, + include_env_credentials=include_env_credentials, + ) + + +def _manager(args: ArgsContainer, *, cli_base_url: str | None) -> PipelineRunManager: + 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, cli_base_url), + ) + return PipelineRunManager(client=client) + + +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: + result = fn(_manager(args, cli_base_url=cli_base_url), args) + except PipelineRunError as exc: + raise SystemExit(str(exc)) from exc + if result is not None: + print_json(result) + + +@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, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, +) -> 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), + "annotation": (annotation, None), + "hydrate": (hydrate, True), + "dry_run": (dry_run, None), + "run_as": (run_as, None), + **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, 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, + *, + 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, +) -> None: + """Print run details, including root execution details.""" + specs = { + "run_id": (run_id,), + "include_annotations": (include_annotations, None), + "include_execution_state": (include_execution_state, None), + **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), + ), + ) + + +@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, +) -> None: + """Print a pipeline run and derived status summary.""" + specs = {"run_id": (run_id,), **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, +) -> None: + """Print graph execution state for an execution id.""" + specs = {"execution_id": (execution_id,), **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, +) -> None: + """Cancel a pipeline run.""" + specs = {"run_id": (run_id,), **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, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, +) -> 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), + **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), + ), + ) + + +@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, +) -> None: + """Print Tangle API container logs for an execution id.""" + specs = {"execution_id": (execution_id,), **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, + page_token: str | None = None, + include_pipeline_names: bool | None = None, + include_execution_stats: bool | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, +) -> None: + """Search/list pipeline runs using the Tangle API filters.""" + specs = { + "query": ("filter", query, None, False), + "filter_query": (filter_query, None), + "page_token": (page_token, None), + "include_pipeline_names": (include_pipeline_names, None), + "include_execution_stats": (include_execution_stats, None), + **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.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, + ), + ) + + +@app.command(name="export") +def pipeline_runs_export( + run_id: str | None = None, + *, + output: pathlib.Path | None = None, + base_url: BaseUrlOption = None, + token: TokenOption = None, + auth_header: AuthHeaderOption = None, + header: HeaderOption = None, + config: ConfigOption = None, +) -> None: + """Export a run's root pipeline spec to YAML.""" + specs = { + "run_id": (run_id,), + "output": (output, None, optional_path), + **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) + 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, +) -> None: + specs = {"run_id": (run_id,), **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.annotations_list(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, +) -> None: + specs = { + "run_id": (run_id,), + "key": (key,), + "value": (value,), + **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.annotations_set(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, +) -> None: + specs = { + "run_id": (run_id,), + "key": (key,), + **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.annotations_delete(args.run_id, args.key), + ) diff --git a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py index 2d57acc..e29daac 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py @@ -7,7 +7,14 @@ from cyclopts import App, Parameter -from .args_container import ArgsContainer, ConfigFileError +from .cli_helpers import load_config_or_exit, optional_path +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + TokenOption, +) from .pipelines import ( PipelineValidationError, generate_mermaid, @@ -16,37 +23,6 @@ validate_pipeline_file, ) -BaseUrlOption = Annotated[ - str | None, - Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), -] -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.", - negative_iterable=(), - ), -] -ConfigOption = Annotated[ - str | None, - Parameter(help="YAML/JSON config file providing command defaults."), -] - app = App( name="pipelines", help="Validate and visualize local Tangle pipeline specs.", @@ -75,36 +51,14 @@ def pipelines_diagram(pipeline_path: pathlib.Path) -> None: print(generate_mermaid(pipeline)) -def _load_config(config: str | None) -> dict[str, object]: - 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 _config_value(config: dict[str, object], key: str) -> object | None: - return config.get(key) if key in config else None - - def _optional_str(value: object) -> str | None: return value if isinstance(value, str) else None -def _optional_path(value: pathlib.Path | str | object | None) -> pathlib.Path | None: - if isinstance(value, pathlib.Path): - return value - if isinstance(value, str): - return pathlib.Path(value) - return 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_value(config, "header") + config_header = config.get("header") if isinstance(config_header, list): return [str(entry) for entry in config_header] if isinstance(config_header, str): @@ -154,26 +108,26 @@ def pipelines_hydrate( ) -> None: """Hydrate a local pipeline YAML file.""" - config_values = _load_config(config) - config_base_url = _optional_str(_config_value(config_values, "base_url")) + 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_value(config_values, "var") + 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_value(config_values, "token")) + resolved_token = _optional_str(config_values.get("token")) try: result = hydrate_pipeline_file( pipeline_path, - output=output or _optional_path(_config_value(config_values, "output")), + 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_value(config_values, "auth_header")) + else _optional_str(config_values.get("auth_header")) ), header=_header_entries(header, config_values), include_env_credentials=include_env_credentials, diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index ccf18ba..7efc802 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -2,44 +2,27 @@ from __future__ import annotations -import json import pathlib from typing import Annotated, Any from cyclopts import App, Parameter -from .args_container import ArgsContainer, ConfigFileError from .api_transport import DEFAULT_TIMEOUT_SECONDS - -BaseUrlOption = Annotated[ - str | None, - Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), -] -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( - alias="-H", - help="Custom request header as 'Name: value'. Repeat for multiple.", - negative_iterable=(), - ), -] -ConfigOption = Annotated[ - str | None, - Parameter(help="YAML/JSON config file providing command defaults."), -] +from .cli_helpers import ( + api_arg_specs, + include_env_credentials_for_args, + load_args_or_exit, + optional_path, + print_json, +) +from .cli_options import ( + AuthHeaderOption, + BaseUrlOption, + ConfigOption, + HeaderOption, + TokenOption, +) +from .component_publisher import ComponentPublisher, deprecate_component app = App( name="published-components", @@ -78,77 +61,6 @@ def _client_from_options( ) -def _print_json(payload: object) -> None: - print(json.dumps(payload, indent=2, sort_keys=True)) - - -def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: - try: - return ArgsContainer.load(config, **kwargs) - except ConfigFileError as exc: - raise SystemExit(f"Config error: {exc}") from exc - - -def _optional_path(value: str | pathlib.Path | None) -> pathlib.Path | None: - return pathlib.Path(value) if value is not None else None - - -def _include_env_credentials(args: ArgsContainer, cli_base_url: str | None) -> bool: - config_base_url = getattr(args, "_config", {}).get("base_url") - return not (cli_base_url is None and config_base_url is not 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, ...]]: - return { - "base_url": (base_url, None), - "token": (token, None), - "auth_header": (auth_header, None), - "header": (header, None), - } - - -def search_components(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import search_components as _search_components - - return _search_components(*args, **kwargs) - - -def inspect_by_digest(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import inspect_by_digest as _inspect_by_digest - - return _inspect_by_digest(*args, **kwargs) - - -def inspect_by_name(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import inspect_by_name as _inspect_by_name - - return _inspect_by_name(*args, **kwargs) - - -def get_standard_library(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import get_standard_library as _get_standard_library - - return _get_standard_library(*args, **kwargs) - - -def ComponentPublisher(*args: Any, **kwargs: Any) -> Any: # noqa: N802 - class-shaped lazy factory - from .component_publisher import ComponentPublisher as _ComponentPublisher - - return _ComponentPublisher(*args, **kwargs) - - -def deprecate_component(*args: Any, **kwargs: Any) -> Any: - from .component_publisher import deprecate_component as _deprecate_component - - return _deprecate_component(*args, **kwargs) - - @app.command(name="search") def published_components_search( name: str | None = None, @@ -164,13 +76,13 @@ def published_components_search( ) -> None: """Search published component metadata.""" - for args in _load_args( + for args in load_args_or_exit( config, name=(name, None), include_deprecated=(include_deprecated, None), published_by=(published_by, None), digest=(digest, None), - **_api_arg_specs( + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -182,9 +94,11 @@ def published_components_search( token=args.token, auth_header=args.auth_header, header=args.header, - include_env_credentials=_include_env_credentials(args, base_url), + include_env_credentials=include_env_credentials_for_args(args, base_url), ) - _print_json( + from .component_inspector import search_components + + print_json( search_components( client, name=args.name, @@ -213,7 +127,7 @@ def published_components_inspect( ) -> None: """Inspect a published component by exact name or digest.""" - for args in _load_args( + for args in load_args_or_exit( config, name=(name, None), digest=(digest, None), @@ -222,7 +136,7 @@ def published_components_inspect( follow_deprecated=(follow_deprecated, None), full_spec=(full_spec, None), published_by=(published_by, None), - **_api_arg_specs( + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -237,9 +151,11 @@ def published_components_inspect( token=args.token, auth_header=args.auth_header, header=args.header, - include_env_credentials=_include_env_credentials(args, base_url), + include_env_credentials=include_env_credentials_for_args(args, base_url), ) if args.digest: + from .component_inspector import inspect_by_digest + result = inspect_by_digest( client, args.digest, @@ -247,6 +163,8 @@ def published_components_inspect( follow_deprecated=bool(args.follow_deprecated), ) else: + from .component_inspector import inspect_by_name + result = inspect_by_name( client, args.name or "", @@ -255,7 +173,7 @@ def published_components_inspect( full_spec=bool(args.full_spec), published_by=args.published_by, ) - _print_json(result) + print_json(result) @app.command(name="library") @@ -269,9 +187,9 @@ def published_components_library( ) -> None: """Print the curated standard component library.""" - for args in _load_args( + for args in load_args_or_exit( config, - **_api_arg_specs( + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -283,9 +201,11 @@ def published_components_library( token=args.token, auth_header=args.auth_header, header=args.header, - include_env_credentials=_include_env_credentials(args, base_url), + include_env_credentials=include_env_credentials_for_args(args, base_url), ) - _print_json(get_standard_library(client)) + from .component_inspector import get_standard_library + + print_json(get_standard_library(client)) @app.command(name="publish") @@ -313,9 +233,9 @@ def published_components_publish( ) -> None: """Publish one component YAML file to a Tangle component registry.""" - all_args = _load_args( + all_args = load_args_or_exit( config, - component_path=("component_path", component_path, None, False, True, _optional_path), + component_path=("component_path", component_path, None, False, True, optional_path), image=(image, None), name=(name, None), description=(description, None), @@ -324,9 +244,9 @@ def published_components_publish( 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), + git_root=(git_root, None, optional_path), published_by=(published_by, None), - **_api_arg_specs( + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -340,7 +260,7 @@ def published_components_publish( token=args.token, auth_header=args.auth_header, header=args.header, - include_env_credentials=_include_env_credentials(args, base_url), + include_env_credentials=include_env_credentials_for_args(args, base_url), ) publisher = ComponentPublisher( dry_run=bool(args.dry_run), @@ -368,7 +288,7 @@ def published_components_publish( "error_count": error_count, "results": results, } - _print_json(summary) + print_json(summary) if error_count: raise SystemExit(1) @@ -386,11 +306,11 @@ def published_components_deprecate( ) -> None: """Deprecate a published component by digest.""" - for args in _load_args( + for args in load_args_or_exit( config, digest=("digest", digest, None, False, True), superseded_by=(superseded_by, None), - **_api_arg_specs( + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -402,7 +322,7 @@ def published_components_deprecate( token=args.token, auth_header=args.auth_header, header=args.header, - include_env_credentials=_include_env_credentials(args, base_url), + include_env_credentials=include_env_credentials_for_args(args, base_url), ) result = deprecate_component( client, @@ -410,6 +330,6 @@ def published_components_deprecate( superseded_by=args.superseded_by, ) result_dict = result.to_dict() if hasattr(result, "to_dict") else result - _print_json(result_dict) + print_json(result_dict) if isinstance(result_dict, dict) and not result_dict.get("success", result_dict.get("status") != "failed"): raise SystemExit(1) diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 628ba11..6503f34 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -5,7 +5,7 @@ import httpx import pytest -from tangle_cli import api_cli, cli, components_cli, published_components_cli +from tangle_cli import api_cli, cli, component_inspector, components_cli, published_components_cli SCHEMA = { @@ -298,12 +298,12 @@ def fake_client_from_options(**kwargs): fake_client_from_options, ) monkeypatch.setattr( - published_components_cli, + component_inspector, "search_components", lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, ) monkeypatch.setattr( - published_components_cli, + component_inspector, "inspect_by_name", lambda client, name, **kwargs: { "client_ok": client is fake_client, @@ -312,7 +312,7 @@ def fake_client_from_options(**kwargs): }, ) monkeypatch.setattr( - published_components_cli, + component_inspector, "inspect_by_digest", lambda client, digest, **kwargs: { "client_ok": client is fake_client, @@ -321,7 +321,7 @@ def fake_client_from_options(**kwargs): }, ) monkeypatch.setattr( - published_components_cli, + component_inspector, "get_standard_library", lambda client: {"client_ok": client is fake_client, "folders": []}, ) @@ -420,7 +420,7 @@ def fake_client_from_options(**kwargs): monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr( - published_components_cli, + component_inspector, "search_components", lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, ) @@ -476,7 +476,7 @@ def fake_client_from_options(**kwargs): monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr( - published_components_cli, + component_inspector, "inspect_by_digest", lambda client, digest, **kwargs: { "client_ok": client is fake_client, @@ -485,7 +485,7 @@ def fake_client_from_options(**kwargs): }, ) monkeypatch.setattr( - published_components_cli, + component_inspector, "get_standard_library", lambda client: {"client_ok": client is fake_client}, ) @@ -712,6 +712,31 @@ def fail_load_cached_schema(base_url): # pragma: no cover - assertion helper 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) diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index 28b67ce..5a5ee34 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -1,18 +1,24 @@ from types import SimpleNamespace +import httpx import pytest -from tangle_cli.api_transport import build_operation_request, default_base_url +from tangle_cli.api_transport import ( + build_operation_request, + default_base_url, + request_operation, + tangle_verbose_enabled, +) -def _operation(path: str) -> SimpleNamespace: +def _operation(path: str, *, method: str = "GET", has_request_body: bool = False) -> SimpleNamespace: return SimpleNamespace( - method="GET", + method=method, path=path, parameters=[], group_name="test", command_name="op", - has_request_body=False, + has_request_body=has_request_body, ) @@ -76,6 +82,87 @@ def test_build_operation_request_allows_explicit_localhost_with_ambient_auth( 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_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"}, + 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"], + 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 "request-token" not in logs + assert "response-secret" not in logs + assert "response-key" not in logs + + def test_build_operation_request_rejects_absolute_url_paths() -> None: with pytest.raises(ValueError, match="must be relative"): build_operation_request( diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py new file mode 100644 index 0000000..b6a59be --- /dev/null +++ b/tests/test_pipeline_runs_cli.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml + +from tangle_cli import cli, pipeline_runs_cli +from tangle_cli.pipeline_runs import PipelineRunManager, PipelineRunError + + +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.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 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 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 + + +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, "_client_from_options", 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_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": { + "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, "_client_from_options", 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_ref = spec["implementation"]["graph"]["tasks"]["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_hydrate_logs_stay_quiet_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, "_client_from_options", 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 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, "_client_from_options", 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, "_client_from_options", 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"]) + assert json.loads(capsys.readouterr().out)["owner"] == "alice" + + 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", "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_wait_is_bounded_and_testable(monkeypatch): + fake_client = FakeClient() + manager = PipelineRunManager(client=fake_client) + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_runs.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", + ) diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 6c00d76..1a8027f 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -60,6 +60,59 @@ def test_generated_graph_state_response_extensions_work_at_runtime() -> None: +@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"}) From fae1c3626471d793be46d0f55b380b734f7f74a8 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 18:28:31 -0700 Subject: [PATCH 048/111] refactor: share sdk CLI options and logging --- .../src/tangle_cli/artifacts_cli.py | 181 ++++-------- .../tangle-cli/src/tangle_cli/cli_helpers.py | 37 +++ .../tangle-cli/src/tangle_cli/cli_options.py | 4 + .../src/tangle_cli/component_generator.py | 3 +- .../src/tangle_cli/components_cli.py | 63 +++-- packages/tangle-cli/src/tangle_cli/logger.py | 50 ++-- .../src/tangle_cli/pipeline_runs.py | 7 +- .../src/tangle_cli/pipeline_runs_cli.py | 105 ++++--- .../tangle-cli/src/tangle_cli/pipelines.py | 2 + .../src/tangle_cli/pipelines_cli.py | 61 +++- .../tangle_cli/published_components_cli.py | 260 +++++++++--------- .../src/tangle_cli/version_manager.py | 1 + tests/test_api_cli.py | 15 +- tests/test_artifacts_cli.py | 14 +- tests/test_components_cli.py | 25 +- tests/test_pipeline_runs_cli.py | 177 +++++++++++- tests/test_pipelines_cli.py | 36 +++ 17 files changed, 664 insertions(+), 377 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py index 7a6c561..9153a83 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py @@ -2,43 +2,27 @@ from __future__ import annotations -import json from typing import Annotated, Any from cyclopts import App, Parameter -from .api_transport import DEFAULT_TIMEOUT_SECONDS -from .args_container import ArgsContainer, ConfigFileError +from .cli_helpers import ( + api_arg_specs, + LazyTangleApiClient, + 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 -BaseUrlOption = Annotated[ - str | None, - Parameter(help="Tangle API base URL. Defaults to TANGLE_API_URL, then localhost."), -] -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( - alias="-H", - help="Custom request header as 'Name: value'. Repeat for multiple.", - negative_iterable=(), - ), -] -ConfigOption = Annotated[ - str | None, - Parameter(help="YAML/JSON config file providing command defaults."), -] QueryOption = Annotated[ str | None, Parameter( @@ -58,80 +42,6 @@ ) -def _print_json(payload: object) -> None: - print(json.dumps(payload, indent=2, sort_keys=True)) - - -def _load_args(config: str | None, **kwargs: Any) -> list[ArgsContainer]: - try: - return ArgsContainer.load(config, **kwargs) - except ConfigFileError as exc: - raise SystemExit(f"Config error: {exc}") from exc - - -def _client_from_options( - *, - base_url: str | None = None, - token: str | None = None, - auth_header: str | None = None, - header: list[str] | None = None, - include_env_credentials: bool = True, -) -> Any: - """Create the static client used by read-only artifact commands.""" - - try: - from .client import TangleApiClient - except ModuleNotFoundError as exc: - if exc.name == "tangle_api": - raise SystemExit( - "Native generated Tangle API bindings are required for " - "artifact commands. Install tangle-cli[native] or provide " - "a local tangle_api.generated package." - ) from exc - raise - - return TangleApiClient( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - timeout=DEFAULT_TIMEOUT_SECONDS, - include_env_credentials=include_env_credentials, - ) - - -def _include_env_credentials(args: ArgsContainer, cli_base_url: str | None) -> bool: - config_base_url = getattr(args, "_config", {}).get("base_url") - return not (cli_base_url is None and config_base_url is not 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, ...]]: - return { - "base_url": (base_url, None), - "token": (token, None), - "auth_header": (auth_header, None), - "header": (header, None), - } - - -def get_artifacts(*args: Any, **kwargs: Any) -> Any: - from .artifacts import get_artifacts as _get_artifacts - - return _get_artifacts(*args, **kwargs) - - -def _serialize_artifacts(*args: Any, **kwargs: Any) -> Any: - from .artifacts import _serialize_artifacts as _serialize - - return _serialize(*args, **kwargs) - - @app.command(name="get") def artifacts_get( run_id: str | None = None, @@ -142,14 +52,16 @@ def artifacts_get( 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( + all_args = load_args_or_exit( config, run_id=("run_id", run_id, None, False, True), query=("query", query, None, True, True), - **_api_arg_specs( + log_type=(log_type, "console"), + **api_arg_specs( base_url=base_url, token=token, auth_header=auth_header, @@ -157,28 +69,37 @@ def artifacts_get( ), ) + from .artifacts import _serialize_artifacts, get_artifacts + results: list[dict[str, Any]] = [] for args in all_args: - 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(args, base_url), - ) + logger, finalize_logs = logger_for_log_type(args.log_type) try: - artifacts = get_artifacts(args.run_id, args.query, client=client) - 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": _serialize_artifacts(artifacts), - } - ) - - _print_json(results[0] if len(results) == 1 else {"status": "success", "results": results}) + 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", + ) + try: + artifacts = get_artifacts(args.run_id, args.query, client=client) + 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": _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_helpers.py b/packages/tangle-cli/src/tangle_cli/cli_helpers.py index 74fbbb7..6f75cca 100644 --- a/packages/tangle-cli/src/tangle_cli/cli_helpers.py +++ b/packages/tangle-cli/src/tangle_cli/cli_helpers.py @@ -63,6 +63,43 @@ def api_arg_specs( } +class LazyTangleApiClient: + """Instantiate the generated API client only when a command uses it. + + Importing CLI modules must stay native-free so local-only commands can run + without the generated ``tangle_api`` package. This proxy delays importing and + 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 __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. diff --git a/packages/tangle-cli/src/tangle_cli/cli_options.py b/packages/tangle-cli/src/tangle_cli/cli_options.py index 672046d..f6080ed 100644 --- a/packages/tangle-cli/src/tangle_cli/cli_options.py +++ b/packages/tangle-cli/src/tangle_cli/cli_options.py @@ -46,3 +46,7 @@ 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/component_generator.py b/packages/tangle-cli/src/tangle_cli/component_generator.py index 21d2cd5..5044e9a 100644 --- a/packages/tangle-cli/src/tangle_cli/component_generator.py +++ b/packages/tangle-cli/src/tangle_cli/component_generator.py @@ -89,10 +89,11 @@ def regenerate_yaml( 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.""" - log = print if verbose else lambda *args, **kwargs: None + log = logger.info if logger is not None else (print if verbose else lambda *args, **kwargs: None) if not python_file.exists(): log(f" ❌ File not found: {python_file}") return False diff --git a/packages/tangle-cli/src/tangle_cli/components_cli.py b/packages/tangle-cli/src/tangle_cli/components_cli.py index 1136a7a..3edc353 100644 --- a/packages/tangle-cli/src/tangle_cli/components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -5,7 +5,8 @@ from cyclopts import App, Parameter from .cli_helpers import load_args_or_exit, optional_path -from .cli_options import ConfigOption +from .cli_options import ConfigOption, LogTypeOption +from .logger import logger_for_log_type app = App(name="components", help="Work with Tangle component definitions.") @@ -104,6 +105,7 @@ def _components_generate_from_python_impl( 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, @@ -117,8 +119,10 @@ def _components_generate_from_python_impl( 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 determine_output_path, regenerate_yaml selected_mode = args.mode or "inline" @@ -131,20 +135,24 @@ def _components_generate_from_python_impl( output_is_dir=False, use_legacy_naming=bool(args.use_legacy_naming), ) - success = 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, - verbose=True, - ) - if not success: - raise SystemExit(1) + try: + success = 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, + verbose=True, + logger=logger, + ) + if not success: + raise SystemExit(1) + finally: + finalize_logs() @generate_app.command(name="from-python") @@ -164,6 +172,7 @@ def components_generate_from_python( 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.""" @@ -179,6 +188,7 @@ def components_generate_from_python( mode=mode, resolve_root=resolve_root, config=config, + log_type=log_type, ) @@ -199,6 +209,7 @@ def components_generate_from_python_function( mode: str | None = None, resolve_root: pathlib.Path | None = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Compatibility alias for `generate from-python`.""" @@ -214,6 +225,7 @@ def components_generate_from_python_function( mode=mode, resolve_root=resolve_root, config=config, + log_type=log_type, ) @@ -227,6 +239,7 @@ def components_bump_version( 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.""" @@ -235,17 +248,23 @@ def components_bump_version( 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: - result = bump_version( - args.yaml_file, - set_version=args.set_version, - update_timestamp=bool(args.update_timestamp), - ) - if result.get("status") != "success": - raise SystemExit(1) + 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/logger.py b/packages/tangle-cli/src/tangle_cli/logger.py index 9a96645..3d8789d 100644 --- a/packages/tangle-cli/src/tangle_cli/logger.py +++ b/packages/tangle-cli/src/tangle_cli/logger.py @@ -91,6 +91,38 @@ def get_default_logger() -> ConsoleLogger: CliLogType = str # Valid values: "console", "none", "file" (Literal not supported by typer) +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. @@ -124,24 +156,10 @@ def run_with_logging( *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). """ - if log_type == "console": - result = fn(_default_logger) - _print_result(result) - return - - # "none" or "file" — capture logs, print result as JSON - capture = CaptureLogger() if log_type == "file" else None - logger: Logger = capture if capture is not None else _null_logger + logger, finalize = logger_for_log_type(log_type) try: result = fn(logger) _print_result(result) finally: - # Write captured logs to a temp file even if fn raised an exception - if capture is not 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) + finalize() diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 97286c5..5c3f7e5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -18,8 +18,7 @@ import yaml -from .api_transport import tangle_verbose_enabled -from .logger import Logger, _null_logger, get_default_logger +from .logger import Logger, get_default_logger from .pipeline_hydrator import PipelineHydrator from .utils import dump_yaml @@ -45,9 +44,7 @@ class PipelineRunHooks: runtime defaults without forking the generic pipeline-run manager. """ - logger: Logger = field( - default_factory=lambda: get_default_logger() if tangle_verbose_enabled() else _null_logger - ) + logger: Logger = field(default_factory=get_default_logger) def read_pipeline_yaml(self, pipeline_path: str | Path) -> dict[str, Any]: path_text = str(pipeline_path) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index 8418d44..8eb13f5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -7,10 +7,10 @@ from cyclopts import App, Parameter -from .api_transport import DEFAULT_TIMEOUT_SECONDS from .args_container import ArgsContainer from .cli_helpers import ( api_arg_specs, + LazyTangleApiClient, include_env_credentials_for_args, load_args_or_exit, optional_path, @@ -21,10 +21,13 @@ BaseUrlOption, ConfigOption, HeaderOption, + LogTypeOption, TokenOption, ) +from .logger import Logger, logger_for_log_type from .pipeline_runs import ( PipelineRunError, + PipelineRunHooks, PipelineRunManager, parse_json_or_key_values, parse_key_value_entries, @@ -35,54 +38,33 @@ app.command(annotations_app) -def _client_from_options( - *, - base_url: str | None = None, - token: str | None = None, - auth_header: str | None = None, - header: list[str] | None = None, - include_env_credentials: bool = True, -) -> Any: - try: - from .client import TangleApiClient - except ModuleNotFoundError as exc: - if exc.name == "tangle_api": - raise SystemExit( - "Native generated Tangle API bindings are required for " - "pipeline-run commands. Install tangle-cli[native] or provide " - "a local tangle_api.generated package." - ) from exc - raise - - return TangleApiClient( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - timeout=DEFAULT_TIMEOUT_SECONDS, - include_env_credentials=include_env_credentials, - ) - - -def _manager(args: ArgsContainer, *, cli_base_url: str | None) -> PipelineRunManager: - client = _client_from_options( +def _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) -> PipelineRunManager: + 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, cli_base_url), + command_name="pipeline-run commands", ) - return PipelineRunManager(client=client) + return PipelineRunManager(client=client, hooks=PipelineRunHooks(logger=logger), 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: - result = fn(_manager(args, cli_base_url=cli_base_url), args) - except PipelineRunError as exc: + logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console")) + except ValueError as exc: raise SystemExit(str(exc)) from exc - if result is not None: - print_json(result) + 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() @app.command(name="submit") @@ -112,6 +94,7 @@ def pipeline_runs_submit( 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.""" @@ -123,6 +106,7 @@ def pipeline_runs_submit( "hydrate": (hydrate, True), "dry_run": (dry_run, None), "run_as": (run_as, None), + "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } @@ -151,12 +135,14 @@ def pipeline_runs_details( 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,), "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( @@ -180,9 +166,14 @@ def pipeline_runs_status( 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,), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header)} + 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) @@ -200,9 +191,14 @@ def pipeline_runs_graph_state( 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,), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header)} + 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)) @@ -215,9 +211,14 @@ def pipeline_runs_cancel( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Cancel a pipeline run.""" - specs = {"run_id": (run_id,), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header)} + 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)) @@ -232,12 +233,14 @@ def pipeline_runs_wait( 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), + "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } _run_manager_action( @@ -261,9 +264,14 @@ def pipeline_runs_logs( 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,), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header)} + 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) @@ -288,6 +296,7 @@ def pipeline_runs_search( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Search/list pipeline runs using the Tangle API filters.""" specs = { @@ -296,6 +305,7 @@ def pipeline_runs_search( "page_token": (page_token, None), "include_pipeline_names": (include_pipeline_names, None), "include_execution_stats": (include_execution_stats, None), + "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } _run_manager_action( @@ -322,11 +332,13 @@ def pipeline_runs_export( 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), + "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } @@ -349,8 +361,13 @@ def pipeline_runs_annotations_list( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: - specs = {"run_id": (run_id,), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header)} + 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.annotations_list(args.run_id)) @@ -365,11 +382,13 @@ def pipeline_runs_annotations_set( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: specs = { "run_id": (run_id,), "key": (key,), "value": (value,), + "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } _run_manager_action( @@ -390,10 +409,12 @@ def pipeline_runs_annotations_delete( 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_manager_action( diff --git a/packages/tangle-cli/src/tangle_cli/pipelines.py b/packages/tangle-cli/src/tangle_cli/pipelines.py index 6ea81ed..99b2bf7 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -374,6 +374,7 @@ def hydrate_pipeline_file( header: list[str] | None = None, include_env_credentials: bool = True, client: Any | None = None, + logger: Any | None = None, ) -> HydrateResult: """Hydrate a local pipeline YAML file using the ported TD hydrator.""" @@ -388,6 +389,7 @@ def hydrate_pipeline_file( auth_header=auth_header, header=header, include_env_credentials=include_env_credentials, + logger=logger, resolution_overrides=dict(overrides or {}), ) hydrated = hydrator.hydrate_file( diff --git a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py index e29daac..7ec49a4 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py @@ -7,14 +7,16 @@ from cyclopts import App, Parameter -from .cli_helpers import load_config_or_exit, optional_path +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, @@ -30,25 +32,41 @@ @app.command(name="validate") -def pipelines_validate(pipeline_path: pathlib.Path) -> None: +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: - validate_pipeline_file(pipeline_path) - except PipelineValidationError as exc: - raise SystemExit(str(exc)) from exc - print(f"Valid pipeline: {pipeline_path}") + 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) -> None: +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: - pipeline = validate_pipeline_file(pipeline_path) - except PipelineValidationError as exc: - raise SystemExit(str(exc)) from exc - print(generate_mermaid(pipeline)) + 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: @@ -105,6 +123,7 @@ def pipelines_hydrate( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Hydrate a local pipeline YAML file.""" @@ -117,6 +136,7 @@ def pipelines_hydrate( 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, @@ -131,9 +151,24 @@ def pipelines_hydrate( ), header=_header_entries(header, config_values), include_env_credentials=include_env_credentials, + logger=logger, + 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") @@ -168,9 +203,11 @@ def pipelines_layout( 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, @@ -181,6 +218,8 @@ def pipelines_layout( ) 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)." diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 7efc802..2f9db32 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -7,9 +7,9 @@ from cyclopts import App, Parameter -from .api_transport import DEFAULT_TIMEOUT_SECONDS from .cli_helpers import ( api_arg_specs, + LazyTangleApiClient, include_env_credentials_for_args, load_args_or_exit, optional_path, @@ -20,8 +20,10 @@ BaseUrlOption, ConfigOption, HeaderOption, + LogTypeOption, TokenOption, ) +from .logger import logger_for_log_type from .component_publisher import ComponentPublisher, deprecate_component app = App( @@ -30,37 +32,6 @@ ) -def _client_from_options( - *, - base_url: str | None = None, - token: str | None = None, - auth_header: str | None = None, - header: list[str] | None = None, - include_env_credentials: bool = True, -) -> Any: - """Create the static client used by published-component commands.""" - - try: - from .client import TangleApiClient - except ModuleNotFoundError as exc: - if exc.name == "tangle_api": - raise SystemExit( - "Native generated Tangle API bindings are required for " - "published-component commands. Install tangle-cli[native] " - "or provide a local tangle_api.generated package." - ) from exc - raise - - return TangleApiClient( - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - timeout=DEFAULT_TIMEOUT_SECONDS, - include_env_credentials=include_env_credentials, - ) - - @app.command(name="search") def published_components_search( name: str | None = None, @@ -73,6 +44,7 @@ def published_components_search( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Search published component metadata.""" @@ -82,6 +54,7 @@ def published_components_search( 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, @@ -89,24 +62,29 @@ def published_components_search( header=header, ), ): - 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), - ) - from .component_inspector import search_components + 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="published-component commands", + ) + from .component_inspector import search_components - print_json( - search_components( - client, - name=args.name, - include_deprecated=bool(args.include_deprecated), - published_by=args.published_by, - digest=args.digest, + print_json( + search_components( + client, + name=args.name, + include_deprecated=bool(args.include_deprecated), + published_by=args.published_by, + digest=args.digest, + ) ) - ) + finally: + finalize_logs() @app.command(name="inspect") @@ -124,6 +102,7 @@ def published_components_inspect( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Inspect a published component by exact name or digest.""" @@ -136,6 +115,7 @@ def published_components_inspect( 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, @@ -143,37 +123,42 @@ def published_components_inspect( header=header, ), ): - 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), - ) - if args.digest: - from .component_inspector import inspect_by_digest + 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") - result = inspect_by_digest( - client, - args.digest, - full_spec=bool(args.full_spec), - follow_deprecated=bool(args.follow_deprecated), + 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="published-component commands", ) - else: - from .component_inspector import inspect_by_name + if args.digest: + from .component_inspector import inspect_by_digest - result = inspect_by_name( - client, - 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) + result = inspect_by_digest( + client, + args.digest, + full_spec=bool(args.full_spec), + follow_deprecated=bool(args.follow_deprecated), + ) + else: + from .component_inspector import inspect_by_name + + result = inspect_by_name( + client, + 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") @@ -184,11 +169,13 @@ def published_components_library( 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, @@ -196,16 +183,21 @@ def published_components_library( header=header, ), ): - 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), - ) - from .component_inspector import get_standard_library + 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="published-component commands", + ) + from .component_inspector import get_standard_library - print_json(get_standard_library(client)) + print_json(get_standard_library(client)) + finally: + finalize_logs() @app.command(name="publish") @@ -230,6 +222,7 @@ def published_components_publish( 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.""" @@ -246,6 +239,7 @@ def published_components_publish( 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, @@ -255,31 +249,37 @@ def published_components_publish( ) results: list[dict[str, Any]] = [] for args in all_args: - 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), - ) - 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, - ) - 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}) + logger, finalize_logs = logger_for_log_type(args.log_type) + try: + client = None if args.dry_run else 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="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 = { @@ -303,6 +303,7 @@ def published_components_deprecate( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + log_type: LogTypeOption = "console", ) -> None: """Deprecate a published component by digest.""" @@ -310,6 +311,7 @@ def published_components_deprecate( 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, @@ -317,19 +319,25 @@ def published_components_deprecate( header=header, ), ): - 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), - ) - result = deprecate_component( - client, - args.digest, - superseded_by=args.superseded_by, - ) - 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) + 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="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/version_manager.py b/packages/tangle-cli/src/tangle_cli/version_manager.py index f3f827a..0c0737a 100644 --- a/packages/tangle-cli/src/tangle_cli/version_manager.py +++ b/packages/tangle-cli/src/tangle_cli/version_manager.py @@ -438,6 +438,7 @@ def bump_version( strip_code=not has_original_code, mode=generation_mode, resolve_root=resolve_root, + logger=log, ) else: log.error(f"❌ Python source not found: {python_path}") diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 6503f34..c45fd95 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -5,7 +5,7 @@ import httpx import pytest -from tangle_cli import api_cli, cli, component_inspector, components_cli, published_components_cli +from tangle_cli import api_cli, cli, cli_helpers, component_inspector, components_cli, published_components_cli SCHEMA = { @@ -294,7 +294,7 @@ def fake_client_from_options(**kwargs): monkeypatch.setattr( published_components_cli, - "_client_from_options", + "LazyTangleApiClient", fake_client_from_options, ) monkeypatch.setattr( @@ -418,7 +418,7 @@ 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) + monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr( component_inspector, "search_components", @@ -452,6 +452,7 @@ def fake_client_from_options(**kwargs): "auth_header": "Bearer config-auth", "header": ["X-Config: yes"], "include_env_credentials": False, + "command_name": "published-component commands", } @@ -474,7 +475,7 @@ 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) + monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr( component_inspector, "inspect_by_digest", @@ -510,16 +511,18 @@ def fake_client_from_options(**kwargs): assert client_calls[-1]["include_env_credentials"] is False -def test_sdk_published_components_client_from_options_uses_static_client(): +def test_lazy_tangle_api_client_uses_static_client(): from tangle_cli.client import TangleApiClient - client = published_components_cli._client_from_options( + 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" diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py index a9d34a2..1098052 100644 --- a/tests/test_artifacts_cli.py +++ b/tests/test_artifacts_cli.py @@ -6,6 +6,7 @@ import sys from typing import Any +from tangle_cli import artifacts as artifacts_module from tangle_cli import artifacts_cli, cli @@ -53,10 +54,10 @@ def fake_get_artifacts(run_id: str, query: dict[str, Any], *, client: object) -> get_calls.append({"run_id": run_id, "query": query, "client": client}) return {"artifact-config": object()} - monkeypatch.setattr(artifacts_cli, "_client_from_options", fake_client_from_options) - monkeypatch.setattr(artifacts_cli, "get_artifacts", fake_get_artifacts) + monkeypatch.setattr(artifacts_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(artifacts_module, "get_artifacts", fake_get_artifacts) monkeypatch.setattr( - artifacts_cli, + artifacts_module, "_serialize_artifacts", lambda artifacts: [{"id": "artifact-config", "uri": "gs://bucket/config", "key": "artifact-config"}], ) @@ -78,6 +79,7 @@ def fake_get_artifacts(run_id: str, query: dict[str, Any], *, client: object) -> "auth_header": "Bearer config-auth", "header": ["X-Config: yes"], "include_env_credentials": False, + "command_name": "artifact commands", } ] assert get_calls == [ @@ -104,9 +106,9 @@ def fake_client_from_options(**kwargs: Any) -> object: client_calls.append(kwargs) return object() - monkeypatch.setattr(artifacts_cli, "_client_from_options", fake_client_from_options) - monkeypatch.setattr(artifacts_cli, "get_artifacts", lambda *args, **kwargs: {}) - monkeypatch.setattr(artifacts_cli, "_serialize_artifacts", lambda artifacts: []) + monkeypatch.setattr(artifacts_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(artifacts_module, "get_artifacts", lambda *args, **kwargs: {}) + monkeypatch.setattr(artifacts_module, "_serialize_artifacts", lambda artifacts: []) app = cli.build_app() run_app( diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 1eac285..04b614e 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -1,6 +1,7 @@ import json from pathlib import Path from typing import Any +from unittest.mock import ANY import pytest import yaml @@ -66,7 +67,7 @@ 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, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -86,6 +87,7 @@ def fake_client_from_options(**kwargs: Any) -> object: "git_root": None, "published_by": None, "client": None, + "logger": ANY, } assert FakePublisher.instances[0].publish_calls == [ { @@ -118,7 +120,7 @@ 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, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -133,6 +135,7 @@ def fake_client_from_options(**kwargs: Any) -> object: "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 @@ -153,7 +156,7 @@ 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, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -249,7 +252,7 @@ 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, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -265,6 +268,7 @@ def fake_client_from_options(**kwargs: Any) -> object: "auth_header": None, "header": None, "include_env_credentials": False, + "command_name": "published-component commands", } ] assert [publisher.kwargs for publisher in FakePublisher.instances] == [ @@ -276,6 +280,7 @@ def fake_client_from_options(**kwargs: Any) -> object: "git_root": None, "published_by": "first@example.com", "client": fake_client, + "logger": ANY, }, { "dry_run": True, @@ -285,6 +290,7 @@ def fake_client_from_options(**kwargs: Any) -> object: "git_root": None, "published_by": "second@example.com", "client": None, + "logger": ANY, }, ] assert FakePublisher.instances[0].publish_calls[0]["component_path"] == first @@ -313,7 +319,7 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict 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, "LazyTangleApiClient", fake_client_from_options) monkeypatch.setattr(published_components_cli, "deprecate_component", fake_deprecate_component) app = cli.build_app() @@ -332,10 +338,16 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict "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"} + { + "client": fake_client, + "digest": "sha256:from-config", + "superseded_by": "sha256:new", + "logger": ANY, + } ] @@ -370,6 +382,7 @@ def test_components_and_published_components_help_reflect_api_split(capsys): 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 diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index b6a59be..f631389 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -116,11 +116,14 @@ def test_pipeline_runs_help_exposes_run_commands_not_local_pipeline_commands(cap 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, "_client_from_options", lambda **kwargs: fake_client) + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) app = cli.build_app() run_app( @@ -171,7 +174,7 @@ def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_ encoding="utf-8", ) fake_client = FakeClient() - monkeypatch.setattr(pipeline_runs_cli, "_client_from_options", lambda **kwargs: fake_client) + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) app = cli.build_app() run_app( @@ -196,7 +199,52 @@ def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_ assert "_source_dir" not in task_ref["spec"] -def test_pipeline_runs_hydrate_logs_stay_quiet_when_verbose_false( +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, @@ -230,11 +278,128 @@ def test_pipeline_runs_hydrate_logs_stay_quiet_when_verbose_false( encoding="utf-8", ) fake_client = FakeClient() - monkeypatch.setattr(pipeline_runs_cli, "_client_from_options", lambda **kwargs: fake_client) + 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 == "" @@ -251,7 +416,7 @@ def fake_client_from_options(**kwargs: Any) -> FakeClient: 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, "_client_from_options", fake_client_from_options) + 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)]) @@ -263,7 +428,7 @@ def fake_client_from_options(**kwargs: Any) -> FakeClient: def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: Path, capsys): fake_client = FakeClient() - monkeypatch.setattr(pipeline_runs_cli, "_client_from_options", lambda **kwargs: fake_client) + 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"]) diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 6e5a6d4..af557fe 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -194,6 +194,42 @@ def test_pipelines_hydrate_renders_template_and_resolves_local_file_refs( 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", From 0370973c611b0f407636ab22479a15bb0ef01bf7 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 18:38:31 -0700 Subject: [PATCH 049/111] fix: guard native API imports in CLI helpers --- .../src/tangle_cli/artifacts_cli.py | 6 +- .../tangle-cli/src/tangle_cli/cli_helpers.py | 5 ++ .../src/tangle_cli/component_publisher.py | 16 ++--- .../tangle_cli/published_components_cli.py | 6 ++ tests/test_artifacts_cli.py | 36 +++++++++++ tests/test_component_publisher.py | 34 +++++++++++ tests/test_components_cli.py | 60 +++++++++++++++++++ 7 files changed, 151 insertions(+), 12 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py index 9153a83..2f621ba 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py @@ -69,8 +69,6 @@ def artifacts_get( ), ) - from .artifacts import _serialize_artifacts, get_artifacts - results: list[dict[str, Any]] = [] for args in all_args: logger, finalize_logs = logger_for_log_type(args.log_type) @@ -83,6 +81,10 @@ def artifacts_get( 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 _serialize_artifacts, get_artifacts + try: artifacts = get_artifacts(args.run_id, args.query, client=client) except RuntimeError as exc: diff --git a/packages/tangle-cli/src/tangle_cli/cli_helpers.py b/packages/tangle-cli/src/tangle_cli/cli_helpers.py index 6f75cca..f2c704b 100644 --- a/packages/tangle-cli/src/tangle_cli/cli_helpers.py +++ b/packages/tangle-cli/src/tangle_cli/cli_helpers.py @@ -96,6 +96,11 @@ def _get_client(self) -> Any: 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) diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index b9bc9e9..1d9998b 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -141,10 +141,8 @@ def perform_version_check( """ log = logger or get_default_logger() - verbose = utils.tangle_verbose_enabled() local_version = spec.version - if verbose: - log.info(f" Local version: {local_version}") + log.info(f" Local version: {local_version}") latest_version = None @@ -194,11 +192,10 @@ def perform_version_check( log.warn(f" Warning: Failed to get version for component {digest[:16]}: {exc}") continue - if verbose: - if latest_version: - log.info(f" Remote version: {latest_version}") - else: - log.info(f" ℹ️ Found {len(existing_components)} component(s) but couldn't extract version") + if latest_version: + log.info(f" Remote version: {latest_version}") + else: + 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 @@ -217,8 +214,7 @@ def perform_version_check( spec=spec, ) - if verbose: - log.info(f" ⏭️ Skipping: Version {local_version} unchanged") + log.info(f" ⏭️ Skipping: Version {local_version} unchanged") return ProcessingResult( outcome=ProcessingOutcome.SKIP, diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 2f9db32..4ac53bb 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -72,6 +72,8 @@ def published_components_search( 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 search_components print_json( @@ -136,6 +138,8 @@ def published_components_inspect( 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() if args.digest: from .component_inspector import inspect_by_digest @@ -193,6 +197,8 @@ def published_components_library( 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 get_standard_library print_json(get_standard_library(client)) diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py index 1098052..48f16ab 100644 --- a/tests/test_artifacts_cli.py +++ b/tests/test_artifacts_cli.py @@ -129,6 +129,42 @@ def fake_client_from_options(**kwargs: Any) -> object: 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") diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py index e9733cd..17130a1 100644 --- a/tests/test_component_publisher.py +++ b/tests/test_component_publisher.py @@ -16,6 +16,7 @@ perform_version_check, publish_component_to_tangle, ) +from tangle_cli.logger import CaptureLogger, NullLogger @dataclass @@ -179,6 +180,39 @@ def test_version_check_skips_unchanged_owner_scoped_version() -> None: 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() diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 04b614e..370b6c0 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -1,4 +1,6 @@ +import builtins import json +import sys from pathlib import Path from typing import Any from unittest.mock import ANY @@ -351,6 +353,64 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict ] +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() From 7dbde0533871566da13191023cb10c6c05193e62 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 18:52:51 -0700 Subject: [PATCH 050/111] feat: add sdk secrets commands --- packages/tangle-cli/src/tangle_cli/cli.py | 2 + packages/tangle-cli/src/tangle_cli/secrets.py | 132 ++++++ .../tangle-cli/src/tangle_cli/secrets_cli.py | 276 +++++++++++++ tests/test_pipelines_cli.py | 1 + tests/test_secrets_cli.py | 388 ++++++++++++++++++ tests/test_tangle_deploy_compat_imports.py | 12 + 6 files changed, 811 insertions(+) create mode 100644 packages/tangle-cli/src/tangle_cli/secrets.py create mode 100644 packages/tangle-cli/src/tangle_cli/secrets_cli.py create mode 100644 tests/test_secrets_cli.py diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py index 3995edb..b82ea9c 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -6,6 +6,7 @@ from . import pipeline_runs_cli from . import pipelines_cli from . import published_components_cli +from . import secrets_cli def build_sdk_app() -> App: @@ -20,6 +21,7 @@ def build_sdk_app() -> 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 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..8eab8a1 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/secrets.py @@ -0,0 +1,132 @@ +"""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 + + +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.""" + + +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) + + +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") + + +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_secrets(client: SecretClient) -> dict[str, Any]: + """List secret metadata without exposing secret values.""" + + response = client.secrets_list() + raw_secrets = _value_from_mapping_or_object(response, "secrets", []) or [] + secrets = [_secret_metadata(secret) for secret in raw_secrets] + return {"status": "success", "count": len(secrets), "secrets": secrets} + + +def create_secret( + client: SecretClient, + 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 = _resolve_secret_value(value, from_env) + secret = client.secrets_create( + secret_name, + secret_value, + description=description, + expires_at=expires_at, + ) + return {"status": "success", "action": "created", "secret": _secret_metadata(secret)} + + +def update_secret( + client: SecretClient, + 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 = _resolve_secret_value(value, from_env) + secret = client.secrets_update( + secret_name, + secret_value, + description=description, + expires_at=expires_at, + ) + return {"status": "success", "action": "updated", "secret": _secret_metadata(secret)} + + +def delete_secret(client: SecretClient, secret_name: str) -> dict[str, Any]: + """Delete a secret using generated static API operations.""" + + client.secrets_delete(secret_name) + return {"status": "success", "action": "deleted", "secret_name": secret_name} 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..a655e67 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/secrets_cli.py @@ -0,0 +1,276 @@ +"""`tangle sdk secrets` command implementation.""" + +from __future__ import annotations + +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 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: + try: + response = input(f"Are you sure you want to delete secret '{secret_name}'? [y/N]: ") + 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]: + from .secrets import list_secrets + + result = list_secrets(client) + 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]: + from .secrets import create_secret + + result = create_secret( + client, + 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]: + from .secrets import update_secret + + result = update_secret( + client, + 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]: + from .secrets import delete_secret + + if not args.force: + _confirm_delete(args.secret_name) + result = delete_secret(client, args.secret_name) + logger.info(f"Deleted secret: {args.secret_name}") + return result + + _run_secret_action(config, base_url, specs, action) diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index af557fe..c06d830 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -67,6 +67,7 @@ def test_sdk_help_includes_pipelines(capsys): 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): diff --git a/tests/test_secrets_cli.py b/tests/test_secrets_cli.py new file mode 100644 index 0000000..984438c --- /dev/null +++ b/tests/test_secrets_cli.py @@ -0,0 +1,388 @@ +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) -> None: + monkeypatch.setattr(builtins, "input", lambda prompt: "n") + app = cli.build_app() + + with pytest.raises(SystemExit) as exc_info: + app(["sdk", "secrets", "delete", "API_TOKEN"]) + + assert "Delete cancelled" in str(exc_info.value) + assert FakeLazyTangleApiClient.instances[0].calls == [] + + +def test_sdk_secrets_delete_force_calls_generated_operation(capsys: pytest.CaptureFixture[str]) -> None: + app = cli.build_app() + + run_app(app, ["sdk", "secrets", "delete", "API_TOKEN", "--force"]) + + result = json.loads(capsys.readouterr().out) + assert result == {"status": "success", "action": "deleted", "secret_name": "API_TOKEN"} + 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_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index b19021a..9479d7c 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -31,6 +31,13 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: get_artifacts, ) from tangle_cli.module_bundler import ModuleBundler + from tangle_cli.secrets import ( + _resolve_secret_value, + create_secret, + delete_secret, + list_secrets, + update_secret, + ) from tangle_cli.version_manager import bump_version from tangle_cli.component_inspector import ( get_standard_library, @@ -133,6 +140,11 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert callable(_collect_execution_artifacts) assert callable(_serialize_artifacts) assert callable(get_artifacts) + assert callable(_resolve_secret_value) + assert callable(list_secrets) + assert callable(create_secret) + assert callable(update_secret) + assert callable(delete_secret) assert callable(get_standard_library) assert callable(inspect_by_digest) assert callable(inspect_by_name) From 8e3e1df51694b0a27bbdfa5d3e87f119d78f9689 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 19:09:55 -0700 Subject: [PATCH 051/111] fix: write secrets delete prompt to stderr --- .../tangle-cli/src/tangle_cli/secrets_cli.py | 5 ++- tests/test_secrets_cli.py | 36 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/secrets_cli.py b/packages/tangle-cli/src/tangle_cli/secrets_cli.py index a655e67..4c03fb1 100644 --- a/packages/tangle-cli/src/tangle_cli/secrets_cli.py +++ b/packages/tangle-cli/src/tangle_cli/secrets_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from typing import Annotated, Any, Callable from cyclopts import App, Parameter @@ -114,8 +115,10 @@ def _secret_mutation_specs( 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(f"Are you sure you want to delete secret '{secret_name}'? [y/N]: ") + 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"}: diff --git a/tests/test_secrets_cli.py b/tests/test_secrets_cli.py index 984438c..6072131 100644 --- a/tests/test_secrets_cli.py +++ b/tests/test_secrets_cli.py @@ -249,24 +249,52 @@ def test_sdk_secrets_update_rejects_missing_value() -> None: assert "either --value or --from-env is required" in str(exc_info.value) -def test_sdk_secrets_delete_prompts_unless_forced(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(builtins, "input", lambda prompt: "n") +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_force_calls_generated_operation(capsys: pytest.CaptureFixture[str]) -> None: +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"]) - result = json.loads(capsys.readouterr().out) + 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"} ] From 36433dc1cd7943b512fe4f0b10251176bc17c27f Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 19:44:47 -0700 Subject: [PATCH 052/111] docs: add quickstart and architecture guide --- README.md | 630 ++++++++++-------- packages/tangle-cli/src/tangle_cli/cli.py | 2 + .../tangle-cli/src/tangle_cli/quickstart.py | 110 +++ 3 files changed, 479 insertions(+), 263 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/quickstart.py diff --git a/README.md b/README.md index 3e77a4a..bee07e2 100644 --- a/README.md +++ b/README.md @@ -2,206 +2,213 @@ [WIP] Private experimental/lab CLI for Tangle, the open-source ML pipeline orchestration platform. -This lab repo is used to iterate on the next CLI shape before promoting changes to the public OSS package. The CLI is built with [Cyclopts](https://cyclopts.readthedocs.io/) and exposes two top-level command groups: +This lab repo is used to iterate on the next CLI shape before promoting changes to the public OSS package. The CLI is built with [Cyclopts](https://cyclopts.readthedocs.io/) and is intentionally split into two command families: -- `tangle api` for OpenAPI-driven commands generated from the Tangle FastAPI schema. -- `tangle sdk` for local SDK/scaffold commands and published-component inspection helpers. +- `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. -## Run locally +Start here: ```bash +uv run tangle quickstart uv run tangle --help uv run tangle api --help uv run tangle sdk --help -uv run tangle sdk components --help -uv run tangle sdk published-components --help ``` -## SDK commands +## 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. -SDK/scaffold commands live under `tangle sdk`. Local component generation/spec helpers are intentionally nested under `sdk components`; root-level `tangle components ...` is not registered in this lab CLI. API-backed published/registry component operations live separately under `sdk published-components` so local component authoring and registry calls do not share the same command group. +### `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 components annotations get -uv run tangle sdk components annotations set -uv run tangle sdk components generate from-python path/to/component.py --image python:3.12 -uv run tangle sdk components generate from-python-function path/to/component.py # compatibility alias -uv run tangle sdk components bump-version path/to/component.yaml +uv run tangle sdk pipelines --help +uv run tangle sdk pipeline-runs --help uv run tangle sdk published-components --help -uv run tangle sdk published-components search transformer -uv run tangle sdk published-components inspect transformer -uv run tangle sdk published-components inspect --digest sha256:... -uv run tangle sdk published-components library -uv run tangle sdk published-components publish path/to/component.yaml --base-url https://api.example -uv run tangle sdk published-components deprecate sha256:... --superseded-by sha256:... -``` - -`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. The command accepts `--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 over config-file values. - -Local components can also be published to, or deprecated in, a Tangle component -registry using the native generated/static API client under `sdk published-components`: +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 sdk published-components publish components/my-component.yaml \ - --base-url https://api.example \ - --image python:3.12 \ - --name "My component" +uv run tangle api refresh --base-url https://api.example \ + --auth-header 'Bearer ...' \ + -H 'X-Gateway-Auth: ...' -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 +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 ``` -`publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), -`--dry-run`, `--published-by`, generic git metadata fields, generic API auth -fields (`--base-url`, `--token`, `--auth-header`, `-H/--header`), 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. `deprecate` accepts `--superseded-by`, the -same generic API auth fields, and `--config`. +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. -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. Batch `publish-all`, Slack notification flags, -dbt generation, from-container generation, and backend-specific search-v2 -workflows remain out of this lab CLI slice. +## Installation and package split -Example publish config: +The repository contains two Python import packages with different responsibilities: -```yaml -component_path: components/my-component.yaml -image: python:3.12 -name: My component -annotations: - owner: platform -base_url: https://api.example -``` +- `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. -Example multi-component publish config: +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: -```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 +```bash +pip install 'tangle-cli[native]' ``` -Example deprecate config: +In this workspace, `uv` installs the workspace `tangle-api` package for development and tests: -```yaml -digest: sha256:old -superseded_by: sha256:new -base_url: https://api.example +```bash +uv run tangle api --help +uv run tangle sdk pipelines validate pipeline.yaml ``` -## API commands - -API commands are pre-generated from the checked-in official Tangle FastAPI/OpenAPI snapshot, so native installs (`tangle-cli[native]`, or development installs with the workspace `tangle-api` package) show resource command groups immediately on a cold cache and command invocations do not require `refresh` first. By default, the CLI uses `--schema-source auto`: official static operations are always present when the native API package is installed, and cached live-backend operations discovered by `tangle api refresh` are included as extensions when they exist. Cached schemas do not override official operations with the same method/path; official definitions win. +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. -Default `tangle-cli` installs without the native `tangle-api` package can still run cache-management commands such as `tangle api refresh` and can dispatch cached operations with `--schema-source cache`. Official static API commands require `tangle-cli[native]` so the packaged `tangle_api.schema` snapshot is available. +## Quick command examples -You can refresh the local schema cache explicitly for a live backend with: +Local-only SDK commands: ```bash -uv run tangle api refresh +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 ``` -When refreshing, the CLI fetches the schema from: +API-backed SDK commands: -```text -$TANGLE_API_URL/openapi.json +```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 ``` -or, when `TANGLE_API_URL` is unset: +Direct API commands: -```text -http://localhost:8000/openapi.json +```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 ``` -You can also pass a base URL explicitly. This caches non-official/live backend schemas such as Oasis for automatic extension discovery: +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 refresh --base-url http://localhost:8000 -uv run tangle api refresh --base-url https://oasis.shopify.io +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 ``` -To delete the live/dynamic schema cache for a base URL without touching the -checked-in official snapshot, run: +Responses are printed as JSON when the backend returns JSON. -```bash -uv run tangle api reset-cache --base-url https://oasis.shopify.io -``` +## Config files -When `--base-url` is omitted, `reset-cache` uses the same base URL resolution as -`refresh`: `TANGLE_API_URL`, then the default local API URL. +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. -Schemas are cached under the OS-specific user cache directory via `platformdirs`, with an `openapi` subdirectory. Common examples include: +```yaml +_defaults: + base_url: https://api.example + auth_header: Bearer ... + header: + - "X-Gateway-Auth: ..." + log_type: none -```text -macOS: ~/Library/Caches/tangle-cli/openapi/ -Linux: ~/.cache/tangle-cli/openapi/ -Windows: %LOCALAPPDATA%\\TangleML\\tangle-cli\\Cache\\openapi\\ +configs: + - filter: active + limit: 10 + - filter: finished ``` -Override the OpenAPI schema cache directory with: - ```bash -export TANGLE_CLI_CACHE_DIR=/path/to/openapi-schema-cache +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 ``` -If your backend requires bearer auth, set a token: +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 -export TANGLE_API_TOKEN=... +uv run tangle api refresh --base-url http://localhost:8000 +uv run tangle api refresh --base-url https://api.example --auth-header 'Bearer ...' ``` -or pass one per command: +`refresh` fetches: -```bash -uv run tangle api refresh --token ... +```text +/openapi.json ``` -For other `Authorization` schemes, use `--auth-header` or `TANGLE_API_AUTH_HEADER` (also accepts the reference-compatible `TANGLE_AUTH_HEADER`). Values can be either the raw authorization value or `Authorization: value`: +Schemas are cached under the OS-specific user cache directory via `platformdirs`, with an `openapi` subdirectory. Override that directory with: ```bash -export TANGLE_API_AUTH_HEADER='Basic ...' -uv run tangle api refresh --auth-header 'Bearer ...' +export TANGLE_CLI_CACHE_DIR=/path/to/openapi-schema-cache ``` -For arbitrary auth or routing headers, including `Cloud-Auth`, use `--header` (alias `-H`). `TANGLE_API_HEADERS` accepts a JSON object (or a newline-separated list of `Name: value` entries): +Delete a cached live schema without touching the checked-in official snapshot: ```bash -export TANGLE_API_HEADERS='{"Cloud-Auth":"...","X-Api-Key":"..."}' -uv run tangle api refresh --header 'Cloud-Auth: ...' -uv run tangle api pipeline-runs list -H 'Cloud-Auth: ...' +uv run tangle api reset-cache --base-url https://api.example ``` -Repeated `--header 'Name: value'` flags can be used with both `tangle api refresh` and generated API commands. Header values are sent to the backend but are not printed by the CLI. - Schema source modes are: -- `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Requires `tangle-cli[native]` for official operations. -- `--schema-source official`: only the checked-in official static schema (OSS-only/core commands). Requires `tangle-cli[native]`. -- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. Does not require the native API package. +- `--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 the option on the resource group: +For resource help, put `--schema-source` on the resource group: ```bash uv run tangle api published-components --schema-source official --help @@ -211,74 +218,76 @@ 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://oasis.shopify.io --body @query.json +uv run tangle api published-components experimental-search \ + --schema-source cache \ + --base-url https://api.example \ + --body @query.json ``` -Omit `--schema-source` to use `auto`, which includes cached-only backend -extensions after refresh while preserving official definitions for core -operations. Use `--schema-source official` to force OSS-only/core commands. +## SDK command details -### Config files +### Local components -Implemented API commands and `tangle sdk published-components` commands accept -`--config path/to/config.yaml` (or JSON) for command defaults. Explicit CLI -arguments take precedence over config-file values. 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. +`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`. -```yaml -_defaults: - base_url: https://api.example - header: - - "Cloud-Auth: ..." +`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. -configs: - - schema_source: cache - filter: active - limit: 10 - - schema_source: cache - filter: finished -``` +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 api pipeline-runs list --config api-config.yaml --limit 5 -uv run tangle sdk published-components search --config components.yaml -``` +uv run tangle sdk published-components publish components/my-component.yaml \ + --base-url https://api.example \ + --image python:3.12 \ + --name "My component" -For generated `tangle api` commands, config keys use the generated CLI -parameter names such as `base_url`, `schema_source`, `body`, and endpoint -parameters like `limit`, `filter`, or `id`. +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 +``` -## Static and dynamic command examples +`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. -OpenAPI resource paths are available as command groups from the checked-in official schema, with cached-only backend extensions included in auto mode after refresh. For example, `/api/pipeline_runs/` becomes `pipeline-runs`, `/api/components/{digest}` becomes `components`, and `/api/published_components/` becomes `published-components`: +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. -```bash -uv run tangle api pipeline-runs list -uv run tangle api pipeline-runs get RUN_ID -uv run tangle api pipeline-runs cancel RUN_ID -uv run tangle api components get DIGEST -uv run tangle api published-components list -uv run tangle api component-libraries get LIBRARY_ID +```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 ``` -Path parameters are positional arguments and query parameters become options. Check generated help for the exact options exposed by the active schema source: +Batch `publish-all`, Slack notification flags, dbt generation, from-container generation, and backend-specific search-v2 workflows remain out of this OSS lab CLI slice. + +### Pipelines and pipeline runs + +Local pipeline commands live under `sdk pipelines`: ```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 list --auth-header 'Bearer ...' -uv run tangle api pipeline-runs list --header 'Cloud-Auth: ...' +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 ``` -Simple JSON request body fields are exposed as options when possible. For complex bodies, pass JSON directly or read it from a file with `@file`: +Pipeline run API/submit commands live under `sdk pipeline-runs`: ```bash -uv run tangle api pipeline-runs create --help -uv run tangle api pipeline-runs create --body @pipeline-run.json +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 ``` -Responses are printed as JSON when the backend returns JSON. +`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 @@ -295,31 +304,15 @@ existing = client.find_existing_components( ) ``` -`TangleApiClient` uses checked-in endpoint methods generated offline from the -native `tangle_api.schema` OpenAPI snapshot into the native -`tangle_api.generated` package, so normal imports do not fetch or parse the -OpenAPI schema. Handwritten semantic -helpers such as -`find_existing_components(...)` return domain models; that helper accepts -component specs, mapping references, or plain names plus optional names/digests -and publisher filters, and returns a de-duplicated `list[ComponentInfo]`. -`ComponentSpec` is a generated OpenAPI model extended with legacy convenience -helpers, and remains re-exported from `tangle_cli.models`. Execution detail -helpers use the generated `GetExecutionInfoResponse` model directly. The top-level -`import tangle_cli` is lightweight and does not import native static bindings; -install the `native` extra or otherwise provide a local `tangle_api.generated` -package before importing `tangle_cli.client`. - -The repository is split into two import packages: `tangle_cli` contains the CLI, -business helpers, dynamic discovery, codegen, runtime base classes, and default -model extensions; `tangle_api` contains only the native checked-in generated -models, operation proxies, and official OpenAPI snapshot for the official OSS -API. Downstream consumers that vendor `tangle_cli` can generate their own local -`tangle_api.generated` package from their schema without vendoring cli-lab's -native generated package or official snapshot. - -To refresh the checked-in generated methods/models from the official Tangle -backend submodule, run: +`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 @@ -328,105 +321,216 @@ 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. If the submodule is missing, initialize it -with `git submodule update --init --recursive`. - -`--out` controls where generated support modules are written. It defaults to -`packages/tangle-api/src/tangle_api/generated`, which is the native generated -package used by the public `tangle_cli/client.py` wrapper. `--operations-class-name` controls the generated -operations class name in `/operations.py`; it defaults to -`GeneratedTangleApiOperations`. `--model-extension-module` points codegen at an -importable module with a `MODEL_EXTENSIONS` mapping from generated model class -names to extension class names. The built-in `tangle_cli.generated_model_extensions` -module is applied first by default, and repeated `--model-extension-module` -values are applied after it in order. Pass an empty string -(`--model-extension-module ""`) to disable the default module. Generated object -models are emitted as private schema-derived bases plus public model classes. Codegen also applies a built-in model alias so FastAPI schemas such as `ComponentSpecOutput` or `ComponentSpecInput` are additionally exposed as the stable public `ComponentSpec` class when `ComponentSpec` is absent from the schema. Add or override aliases with `--model-alias PublicModel=SourceSchema[,OtherSourceSchema]`; pass `--model-alias ""` to disable built-in aliases. For example: +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. -```python -MODEL_EXTENSIONS = { - "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions", -} +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 private generated 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 ``` -For models without extensions, codegen still emits a public subclass such as -`class OtherResponse(_OtherResponseGenerated): pass` so the exported class keeps -its public OpenAPI name. When multiple extension modules target the same model, -later/downstream extensions are leftmost in the public class MRO and override -earlier/default extensions while schema-derived data remains available via -`to_dict()`. Duplicate extension class names from different modules are imported -with deterministic aliases. Extension classes must be importable from their -modules and should not import generated model classes. +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 generators can explicitly override a specific operation's JSON request-body schema without mutating the fetched OpenAPI document. This is useful when a backend schema is too specific or recursive for generated keyword arguments and the operation should accept an open-ended raw body. Use the OpenAPI `operationId`, generated method name, or `group.command` name: +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 \ - --request-body-schema 'search_create={"type":"object","additionalProperties":true,"title":"SearchQuery"}' + --openapi-url https://api.example/openapi.json \ + --out src/tangle_api/generated \ + --model-extension-module my_project.tangle_model_extensions ``` -For larger schemas, use a JSON file: +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 \ - --request-body-schema-file search_create=search_query.json + --from-snapshot \ + --model-extension-module "" ``` -These request-body overrides are generic and opt-in; OSS codegen has no built-in behavior for experimental downstream endpoints. Codegen writes exactly these support files: +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`. -```text -/__init__.py -/models.py -/operations.py -``` +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. -The generated models import shared runtime helpers from `tangle_cli.generated_runtime`. -The public client remains handwritten at `tangle_cli/client.py`; codegen does not -create a default generated public client wrapper. +## Extending SDK behavior -To regenerate from the already checked-in API-package snapshot instead of the -backend, pass `--from-snapshot` explicitly: +The CLI exposes small explicit seams rather than requiring downstream forks. -```bash -uv run python -m tangle_cli.openapi.codegen --from-snapshot +### 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) ``` -If you already have a remote OpenAPI JSON document, fetch that directly instead: +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`. -```bash -uv run python -m tangle_cli.openapi.codegen --openapi-url https://example.com/openapi.json --out src/tangle_api/generated +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()) ``` -For example, the raw GitHub snapshot form is expressible as: +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 python -m tangle_cli.openapi.codegen --openapi-url https://raw.githubusercontent.com/TangleML/tangle/master/openapi.json +uv run --frozen pytest -q +uv build --sdist --wheel +uv build --sdist --wheel --package tangle-api +git diff --check ``` -Downstream tools can point `--out` at their own generated support package, e.g.: +Targeted CLI smoke: ```bash -uv run python -m tangle_cli.openapi.codegen \ - --openapi-url https://oasis.shopify.io/openapi.json \ - --out src/tangle_api/generated \ - --operations-class-name GeneratedTangleApiExtensions \ - --model-extension-module tangle_deploy.tangle_api_model_extensions \ - --request-body-schema 'search_create={"type":"object","additionalProperties":true,"title":"SearchQuery"}' +uv run tangle quickstart +uv run tangle api --help +uv run tangle sdk --help ``` - -At the time of writing the official repository does not commit that raw -`openapi.json`, so the submodule backend import flow above is the recommended -official regeneration path. diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py index b82ea9c..bc7bbe3 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -6,6 +6,7 @@ from . import pipeline_runs_cli from . import pipelines_cli from . import published_components_cli +from . import quickstart from . import secrets_cli @@ -32,6 +33,7 @@ def build_app() -> App: help="CLI for Tangle, the open-source ML pipeline orchestration platform.", version="0.0.1", ) + app.command(quickstart.app) app.command(api_cli.build_app()) app.command(build_sdk_app()) return app 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) From 8b43b5cf04f7754ec7e53ed83ddcd9b5154fcae1 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 12 Jun 2026 21:07:54 -0700 Subject: [PATCH 053/111] feat: add downstream compatibility seams and codegen omit-none --- .../src/tangle_api/generated/operations.py | 2 +- .../src/tangle_cli/component_from_func.py | 56 +++++++----- .../src/tangle_cli/openapi/codegen.py | 25 +++++- .../tangle_cli/published_components_cli.py | 66 +++++++++++--- tests/test_api_cli.py | 22 ++--- tests/test_codegen.py | 86 ++++++++++++++++++- tests/test_component_generator.py | 56 ++++++++++++ tests/test_components_cli.py | 10 +-- tests/test_static_client.py | 26 ++++++ 9 files changed, 292 insertions(+), 57 deletions(-) diff --git a/packages/tangle-api/src/tangle_api/generated/operations.py b/packages/tangle-api/src/tangle_api/generated/operations.py index 5b9ba85..c946eb9 100644 --- a/packages/tangle-api/src/tangle_api/generated/operations.py +++ b/packages/tangle-api/src/tangle_api/generated/operations.py @@ -292,7 +292,7 @@ def published_components_create(self, digest: Any = None, name: Any = None, tag: '/api/published_components/', path_params=None, params=None, - json_data={'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}, + 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, ) diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index 91e991f..85f6d1b 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -1660,6 +1660,8 @@ def generate_component_yaml( 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. @@ -1677,11 +1679,20 @@ def generate_component_yaml( 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: @@ -1744,36 +1755,39 @@ def generate_component_yaml( if deps: annotations["python_dependencies"] = json.dumps(deps) - annotations["tangle_cli_generation_function_name"] = resolved_func_name - annotations["tangle_cli_generation_mode"] = mode + 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, regardless of whether the files live in a git repo. + # 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 _generation_path_annotation(path: Path) -> str: - try: - return str(path.resolve().relative_to(common_dir)) - except ValueError: - return str(path) - - try: - if not strip_source_path: - annotations["python_original_code_path"] = str(resolved_source.relative_to(common_dir)) - annotations["component_yaml_path"] = str(resolved_output.relative_to(common_dir)) - except ValueError: - annotations["component_yaml_path"] = output_path.name - if dependencies_from: - annotations["tangle_cli_generation_dependencies_from"] = _generation_path_annotation(dependencies_from) - if resolve_root: - annotations["tangle_cli_generation_resolve_root"] = _generation_path_annotation(resolve_root) + 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 - # Git info — use the same common ancestor as git_relative_dir. - git_root = get_git_root(directory) + 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) diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index 485e658..08c0026 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -617,7 +617,7 @@ def _param_signature( has_request_body: bool, *, raw_body_override: bool = False, -) -> tuple[str, list[str], list[str], list[str], bool]: +) -> tuple[str, list[str], list[str], list[str], set[str], bool]: required: list[Any] = [] optional: list[Any] = [] for parameter in parameters: @@ -628,6 +628,7 @@ def _param_signature( 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: @@ -643,11 +644,13 @@ def _param_signature( 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, include_body + return ", ".join(signature_parts), path_names, query_names, body_names, required_body_names, include_body def _dict_literal(names: list[str]) -> str: @@ -656,6 +659,20 @@ def _dict_literal(names: list[str]) -> str: 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.""" @@ -719,7 +736,7 @@ def generate_operations( 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, include_body = _param_signature( + 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")), @@ -740,7 +757,7 @@ def generate_operations( f" params={_dict_literal(query_names)},", ]) if body_names: - lines.append(f" json_data={_dict_literal(body_names)},") + lines.append(f" json_data={_body_dict_literal(body_names, required_body_names)},") elif include_body: lines.append(" json_data=body,") else: diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 4ac53bb..5fc7183 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -26,6 +26,56 @@ from .logger import logger_for_log_type from .component_publisher import ComponentPublisher, deprecate_component + +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, + ) + + +def search_components(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import search_components as _search_components + + return _search_components(*args, **kwargs) + + +def inspect_by_digest(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import inspect_by_digest as _inspect_by_digest + + return _inspect_by_digest(*args, **kwargs) + + +def inspect_by_name(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import inspect_by_name as _inspect_by_name + + return _inspect_by_name(*args, **kwargs) + + +def get_standard_library(*args: Any, **kwargs: Any) -> Any: + from .component_inspector import get_standard_library as _get_standard_library + + return _get_standard_library(*args, **kwargs) + + app = App( name="published-components", help="Inspect and search published Tangle components from the registry.", @@ -64,7 +114,7 @@ def published_components_search( ): logger, finalize_logs = logger_for_log_type(args.log_type) try: - client = LazyTangleApiClient( + client = _client_from_options( base_url=args.base_url, token=args.token, auth_header=args.auth_header, @@ -74,7 +124,6 @@ def published_components_search( ) if require_available := getattr(client, "require_available", None): require_available() - from .component_inspector import search_components print_json( search_components( @@ -130,7 +179,7 @@ def published_components_inspect( if bool(args.name) == bool(args.digest): raise SystemExit("Provide exactly one of NAME or --digest DIGEST") - client = LazyTangleApiClient( + client = _client_from_options( base_url=args.base_url, token=args.token, auth_header=args.auth_header, @@ -141,8 +190,6 @@ def published_components_inspect( if require_available := getattr(client, "require_available", None): require_available() if args.digest: - from .component_inspector import inspect_by_digest - result = inspect_by_digest( client, args.digest, @@ -150,8 +197,6 @@ def published_components_inspect( follow_deprecated=bool(args.follow_deprecated), ) else: - from .component_inspector import inspect_by_name - result = inspect_by_name( client, args.name or "", @@ -189,7 +234,7 @@ def published_components_library( ): logger, finalize_logs = logger_for_log_type(args.log_type) try: - client = LazyTangleApiClient( + client = _client_from_options( base_url=args.base_url, token=args.token, auth_header=args.auth_header, @@ -199,7 +244,6 @@ def published_components_library( ) if require_available := getattr(client, "require_available", None): require_available() - from .component_inspector import get_standard_library print_json(get_standard_library(client)) finally: @@ -257,7 +301,7 @@ def published_components_publish( for args in all_args: logger, finalize_logs = logger_for_log_type(args.log_type) try: - client = None if args.dry_run else LazyTangleApiClient( + client = None if args.dry_run else _client_from_options( base_url=args.base_url, token=args.token, auth_header=args.auth_header, @@ -327,7 +371,7 @@ def published_components_deprecate( ): logger, finalize_logs = logger_for_log_type(args.log_type) try: - client = LazyTangleApiClient( + client = _client_from_options( base_url=args.base_url, token=args.token, auth_header=args.auth_header, diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index c45fd95..736364d 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -5,7 +5,7 @@ import httpx import pytest -from tangle_cli import api_cli, cli, cli_helpers, component_inspector, components_cli, published_components_cli +from tangle_cli import api_cli, cli, cli_helpers, components_cli, published_components_cli SCHEMA = { @@ -294,16 +294,16 @@ def fake_client_from_options(**kwargs): monkeypatch.setattr( published_components_cli, - "LazyTangleApiClient", + "_client_from_options", fake_client_from_options, ) monkeypatch.setattr( - component_inspector, + published_components_cli, "search_components", lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, ) monkeypatch.setattr( - component_inspector, + published_components_cli, "inspect_by_name", lambda client, name, **kwargs: { "client_ok": client is fake_client, @@ -312,7 +312,7 @@ def fake_client_from_options(**kwargs): }, ) monkeypatch.setattr( - component_inspector, + published_components_cli, "inspect_by_digest", lambda client, digest, **kwargs: { "client_ok": client is fake_client, @@ -321,7 +321,7 @@ def fake_client_from_options(**kwargs): }, ) monkeypatch.setattr( - component_inspector, + published_components_cli, "get_standard_library", lambda client: {"client_ok": client is fake_client, "folders": []}, ) @@ -418,9 +418,9 @@ def fake_client_from_options(**kwargs): client_calls.append(kwargs) return fake_client - monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr( - component_inspector, + published_components_cli, "search_components", lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, ) @@ -475,9 +475,9 @@ def fake_client_from_options(**kwargs): client_calls.append(kwargs) return fake_client - monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr( - component_inspector, + published_components_cli, "inspect_by_digest", lambda client, digest, **kwargs: { "client_ok": client is fake_client, @@ -486,7 +486,7 @@ def fake_client_from_options(**kwargs): }, ) monkeypatch.setattr( - component_inspector, + published_components_cli, "get_standard_library", lambda client: {"client_ok": client is fake_client}, ) diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 34d46cb..9a3b3a0 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -879,7 +879,7 @@ def _request_json(self, *args, **kwargs): assert client.calls[0][1]["json_data"] is payload -def test_generate_operations_without_request_body_override_keeps_body_kwargs(tmp_path) -> None: +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( @@ -894,7 +894,10 @@ def test_generate_operations_without_request_body_override_keeps_body_kwargs(tmp "application/json": { "schema": { "type": "object", - "properties": {"query": {"type": "string"}}, + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, } } } @@ -910,10 +913,85 @@ def test_generate_operations_without_request_body_override_keeps_body_kwargs(tmp codegen.generate(openapi, out) operations = (out / "operations.py").read_text(encoding="utf-8") - assert "def search_create(self, query: Any = None)" in operations - assert "json_data={'query': query}" in operations + 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]] = [] diff --git a/tests/test_component_generator.py b/tests/test_component_generator.py index 9b83b1a..eeee9ed 100644 --- a/tests/test_component_generator.py +++ b/tests/test_component_generator.py @@ -8,6 +8,7 @@ import yaml from tangle_cli import cli +from tangle_cli.component_from_func import generate_component_yaml from tangle_cli.component_generator import ( determine_output_path, find_dependencies_file, @@ -141,6 +142,61 @@ def test_complete_generation_flow(monkeypatch, tmp_path: Path, use_cli: bool): _assert_yaml_matches(yaml_file, SNAPSHOTS_DIR / "complete_generation.expected.yaml") +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" diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 370b6c0..1dc4708 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -69,7 +69,7 @@ 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, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -122,7 +122,7 @@ def fake_client_from_options(**kwargs: Any) -> object: client_calls.append(kwargs) return fake_client - monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -158,7 +158,7 @@ def fake_client_from_options(**kwargs: Any) -> object: client_calls.append(kwargs) return object() - monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -254,7 +254,7 @@ def fake_client_from_options(**kwargs: Any) -> object: client_calls.append(kwargs) return fake_client - monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) + monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) monkeypatch.setattr(published_components_cli, "ComponentPublisher", FakePublisher) app = cli.build_app() @@ -321,7 +321,7 @@ def fake_deprecate_component(client: object, digest: str, **kwargs: Any) -> dict deprecate_calls.append({"client": client, "digest": digest, **kwargs}) return {"success": True, "digest": digest, "superseded_by": kwargs.get("superseded_by")} - monkeypatch.setattr(published_components_cli, "LazyTangleApiClient", fake_client_from_options) + 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() diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 1a8027f..602df26 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -487,6 +487,32 @@ def test_get_run_details_preserves_raw_graph_implementations_when_requested() -> 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({ From a806deb263dff191c0fd6055a24a94162c3e4e48 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 00:38:21 -0700 Subject: [PATCH 054/111] fix(tangle-cli): address TD vendoring review findings --- .../src/tangle_cli/api_transport.py | 12 ++++++-- .../src/tangle_cli/component_from_func.py | 2 +- .../src/tangle_cli/pipeline_runs.py | 15 ++++++---- tests/test_api_transport.py | 10 ++++++- tests/test_component_from_func.py | 28 +++++++++++++++++++ tests/test_pipeline_runs_cli.py | 5 +++- 6 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index e9fdddd..b6d9901 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -17,7 +17,10 @@ _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|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential)", re.IGNORECASE) +_SENSITIVE_KEY_RE = re.compile( + r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential)", + re.IGNORECASE, +) _REDACTED = "" @@ -31,7 +34,12 @@ def tangle_verbose_enabled() -> bool: def _redact_headers(headers: dict[str, Any] | None) -> dict[str, Any]: redacted: dict[str, Any] = {} for name, value in (headers or {}).items(): - redacted[name] = _REDACTED if name.lower() in _SENSITIVE_HEADER_NAMES else value + normalized_name = name.lower() + redacted[name] = ( + _REDACTED + if normalized_name in _SENSITIVE_HEADER_NAMES or _SENSITIVE_KEY_RE.search(name) + else value + ) return redacted diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index 85f6d1b..68a2fcf 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -225,7 +225,7 @@ def _ensure_tangle_deploy_authoring_shim() -> None: python_pipeline_mod = types.ModuleType("tangle_deploy.python_pipeline") for name in ("task", "pipeline", "subpipeline"): setattr(python_pipeline_mod, name, _identity_decorator) - for name in ("In", "Out", "TaskEnv"): + for name in ("In", "Out", "Outputs", "TaskEnv"): setattr(python_pipeline_mod, name, _AuthoringGeneric) setattr(python_pipeline_mod, "ref", lambda *args, **kwargs: None) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 5c3f7e5..d765a5f 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -172,11 +172,13 @@ def convert_yaml_to_payload( def sanitize_submit_payload(value: Any) -> Any: """Return a submit-safe payload with TD-compatible componentRef fixes. - The hydrator uses underscore-prefixed annotations such as ``_source_dir`` - while recursively resolving local files. Those are local provenance - only and must not be submitted to the backend. TD also normalizes - ``componentRef.text`` into ``componentRef.spec`` for component-library - entries before submit; keep the same behavior here. + 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): @@ -184,9 +186,10 @@ def sanitize_submit_payload(value: Any) -> Any: 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).startswith("_"): + if str(key) in local_only_keys: continue cleaned[key] = PipelineRunManager.sanitize_submit_payload(item) diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index 5a5ee34..a881a1f 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -4,6 +4,7 @@ import pytest from tangle_cli.api_transport import ( + _redact_headers, build_operation_request, default_base_url, request_operation, @@ -125,6 +126,12 @@ def fake_request(*args, **kwargs): 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], @@ -146,7 +153,7 @@ def fake_request(*args, **kwargs): {}, base_url="https://api.test", auth_header="Bearer request-secret", - header_entries=["Cloud-Auth: cloud-secret"], + header_entries=["Cloud-Auth: cloud-secret", "X-Gateway-Auth: gateway-secret"], body={"name": "demo", "token": "request-token"}, ) @@ -158,6 +165,7 @@ def fake_request(*args, **kwargs): 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 diff --git a/tests/test_component_from_func.py b/tests/test_component_from_func.py index 2d257b7..948ea1a 100644 --- a/tests/test_component_from_func.py +++ b/tests/test_component_from_func.py @@ -2029,6 +2029,34 @@ def multi_name(out: components.OutputPath("Text"), who: str = "world"): # ============================================================================ +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`` diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index f631389..412f40e 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -160,6 +160,7 @@ def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_ "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", @@ -193,7 +194,9 @@ def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_ assert fake_client.created == [] spec = payload["root_task"]["componentRef"]["spec"] assert "_source_dir" not in spec - task_ref = spec["implementation"]["graph"]["tasks"]["task"]["componentRef"] + 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"] From 61c5210355d33720392ec46d69f4c0a7a1a84ef3 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 01:40:18 -0700 Subject: [PATCH 055/111] fix(tangle-cli): pin default component image --- .../src/tangle_cli/component_generator.py | 6 +++++- tests/test_component_generator.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/component_generator.py b/packages/tangle-cli/src/tangle_cli/component_generator.py index 5044e9a..6a620e3 100644 --- a/packages/tangle-cli/src/tangle_cli/component_generator.py +++ b/packages/tangle-cli/src/tangle_cli/component_generator.py @@ -7,7 +7,11 @@ import yaml -DEFAULT_CONTAINER_IMAGE = "python:3.12" +# 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" def find_dependencies_file(python_file: Path) -> Path | None: diff --git a/tests/test_component_generator.py b/tests/test_component_generator.py index eeee9ed..bd22fed 100644 --- a/tests/test_component_generator.py +++ b/tests/test_component_generator.py @@ -10,6 +10,7 @@ from tangle_cli import cli from tangle_cli.component_from_func import generate_component_yaml from tangle_cli.component_generator import ( + DEFAULT_CONTAINER_IMAGE, determine_output_path, find_dependencies_file, regenerate_yaml, @@ -142,6 +143,18 @@ def test_complete_generation_flow(monkeypatch, tmp_path: Path, use_cli: bool): _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, From 9aff07cf5810db8bbfbf62147ae68d86fdf6809d Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 02:46:40 -0700 Subject: [PATCH 056/111] fix(tangle-cli): make run status precedence deterministic --- .../src/tangle_cli/pipeline_runs.py | 4 ++-- tests/test_pipeline_runs_cli.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index d765a5f..7a0ac9e 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -22,8 +22,8 @@ from .pipeline_hydrator import PipelineHydrator from .utils import dump_yaml -_TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "CANCELLED", "CANCELED", "SYSTEM_ERROR", "SKIPPED"} -_ACTIVE_STATUSES = {"RUNNING", "PENDING", "QUEUED", "CANCELLING", "CANCELING"} +_TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED") +_ACTIVE_STATUSES = ("RUNNING", "CANCELLING", "CANCELING", "PENDING", "QUEUED") class PipelineRunError(RuntimeError): diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 412f40e..153488b 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -472,6 +472,26 @@ def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: assert yaml.safe_load(output.read_text(encoding="utf-8"))["name"] == "Exported" +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) From 970bb75fc324791161d4df66a2c5ea31eff3e1df Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 04:21:14 -0700 Subject: [PATCH 057/111] fix(tangle-cli): redact verbose document bodies --- .../src/tangle_cli/api_transport.py | 11 +++++++ tests/test_api_transport.py | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index b6d9901..74acc32 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -22,6 +22,15 @@ re.IGNORECASE, ) _REDACTED = "" +_REDACTED_DOCUMENT = "" +_OPAQUE_DOCUMENT_KEY_NAMES = { + "component_yaml", + "dockerfile", + "manifest", + "pipeline_yaml", + "text", + "yaml", +} def tangle_verbose_enabled() -> bool: @@ -46,6 +55,8 @@ def _redact_headers(headers: dict[str, Any] | None) -> dict[str, Any]: 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): diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index a881a1f..fb2d250 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -171,6 +171,39 @@ def fake_request(*args, **kwargs): assert "response-key" 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( From 4b59cdbc38c2e01b1814b42aa7f2b784ac8c14d1 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 05:13:54 -0700 Subject: [PATCH 058/111] fix(tangle-cli): gate local python hydration execution --- .../src/tangle_cli/api_transport.py | 2 +- .../src/tangle_cli/hydration_trust.py | 204 ++++++++++++++++++ .../src/tangle_cli/pipeline_hydrator.py | 17 +- .../src/tangle_cli/pipeline_runs.py | 11 +- .../src/tangle_cli/pipeline_runs_cli.py | 54 ++++- .../tangle-cli/src/tangle_cli/pipelines.py | 4 + .../src/tangle_cli/pipelines_cli.py | 46 +++- tests/test_api_transport.py | 7 +- tests/test_pipeline_runs_cli.py | 127 +++++++++++ tests/test_pipelines_cli.py | 182 ++++++++++++++++ 10 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/hydration_trust.py diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index 74acc32..b128070 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -18,7 +18,7 @@ _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)", + r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential|pre[-_]?signed[-_]?url|signed[-_]?url)", re.IGNORECASE, ) _REDACTED = "" 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..9f86110 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/hydration_trust.py @@ -0,0 +1,204 @@ +"""Trust controls for hydration features that can execute local Python code.""" + +from __future__ import annotations + +import fnmatch +import os +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() + pattern = (prefix / Path(*suffix_parts)).as_posix() + return fnmatch.fnmatch(candidate.as_posix(), pattern) + + +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/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 85dd8f4..c78af00 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -24,6 +24,7 @@ from . import utils from .api_transport import DEFAULT_TIMEOUT_SECONDS from .component_generator import regenerate_yaml +from .hydration_trust import is_trusted_python_source, trusted_python_source_guidance from .logger import Logger, get_default_logger from .utils import add_official_prefix @@ -138,6 +139,8 @@ def __init__( header: list[str] | None = None, include_env_credentials: bool = True, component_resolvers: Mapping[str, ComponentResolver] | None = None, + trusted_python_sources: list[str] | None = None, + allow_all_hydration: bool = False, ) -> None: self.client = client self._client_options = { @@ -157,6 +160,8 @@ def __init__( if component_resolvers: self.component_resolvers.update(component_resolvers) 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._resolution_overrides_str: dict[str, str] = { k: str(v) for k, v in self.resolution_overrides.items() } @@ -658,6 +663,16 @@ def _resolve_path(p: str | Path | None) -> Path | None: 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 HydrationError(trusted_python_source_guidance(python_file)) output_folder = _resolve_path(gen_config.get("output_folder")) if output_folder is None: @@ -678,7 +693,7 @@ def _resolve_path(p: str | Path | None) -> Path | None: strip_code=bool(gen_config.get("strip_code", False)), verbose=False, mode=str(gen_config.get("mode", "inline")), - resolve_root=_resolve_path(gen_config.get("resolve_root")), + resolve_root=resolve_root, ) if not success or not out_path.exists(): self.log.warn(f" ⚠️ local_from_python failed to generate {out_path}") diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 7a0ac9e..c1b29c3 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -19,7 +19,7 @@ import yaml from .logger import Logger, get_default_logger -from .pipeline_hydrator import PipelineHydrator +from .pipeline_hydrator import HydrationError, PipelineHydrator from .utils import dump_yaml _TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED") @@ -45,6 +45,8 @@ class PipelineRunHooks: """ 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) @@ -70,8 +72,13 @@ def hydrate_pipeline( client=client, resolution_overrides=resolution_overrides, logger=self.logger, + trusted_python_sources=self.trusted_python_sources, + allow_all_hydration=self.allow_all_hydration, ) - return hydrator.hydrate_file(pipeline_path).data + try: + return hydrator.hydrate_file(pipeline_path).data + except HydrationError as exc: + raise PipelineRunError(str(exc)) from exc def prepare_run_arguments( self, diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index 8eb13f5..d6d4edd 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -38,6 +38,33 @@ 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 _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) -> PipelineRunManager: client = LazyTangleApiClient( base_url=args.base_url, @@ -47,7 +74,15 @@ def _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) - include_env_credentials=include_env_credentials_for_args(args, cli_base_url), command_name="pipeline-run commands", ) - return PipelineRunManager(client=client, hooks=PipelineRunHooks(logger=logger), logger=logger) + return PipelineRunManager( + client=client, + 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): @@ -89,6 +124,21 @@ def pipeline_runs_submit( 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, @@ -106,6 +156,8 @@ def pipeline_runs_submit( "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), } diff --git a/packages/tangle-cli/src/tangle_cli/pipelines.py b/packages/tangle-cli/src/tangle_cli/pipelines.py index 99b2bf7..fa2a0a0 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -375,6 +375,8 @@ def hydrate_pipeline_file( 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.""" @@ -391,6 +393,8 @@ def hydrate_pipeline_file( 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, diff --git a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py index 7ec49a4..4f545d7 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py @@ -3,7 +3,7 @@ from __future__ import annotations import pathlib -from typing import Annotated +from typing import Annotated, Any from cyclopts import App, Parameter @@ -84,6 +84,33 @@ def _header_entries(cli_header: list[str] | None, config: dict[str, object]) -> 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): @@ -122,6 +149,21 @@ def pipelines_hydrate( 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: @@ -152,6 +194,8 @@ def pipelines_hydrate( 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, diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index fb2d250..c3ea83a 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -141,7 +141,11 @@ def test_request_operation_verbose_env_logs_redacted_body( def fake_request(*args, **kwargs): return httpx.Response( 200, - json={"id": "run-1", "secret": "response-secret"}, + 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/"), ) @@ -169,6 +173,7 @@ def fake_request(*args, **kwargs): 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( diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 153488b..c40843c 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -472,6 +472,133 @@ def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: assert yaml.safe_load(output.read_text(encoding="utf-8"))["name"] == "Exported" +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 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 / "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(**kwargs): + raise AssertionError("untrusted local_from_python must be blocked before generation") + + monkeypatch.setattr(hydrator_module, "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 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 / "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(**kwargs): + raise AssertionError("untrusted resolve_root must not authorize execution") + + monkeypatch.setattr(hydrator_module, "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 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_submit_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: Submit Generated Component\nimplementation:\n container:\n image: busybox\n", + encoding="utf-8", + ) + return True + + fake_client = FakeClient() + monkeypatch.setattr(hydrator_module, "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_run_status_uses_deterministic_precedence() -> None: run = { "execution_status_stats": { diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index c06d830..ae4fe2f 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -474,6 +474,188 @@ def read(self): 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_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, From cab24cb1718472bb097d56b3bcd28728cecb8d0c Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 07:33:52 -0700 Subject: [PATCH 059/111] feat: add pipeline hydrator downstream extension seams --- .../src/tangle_cli/pipeline_hydrator.py | 392 ++++++++++++++++-- tests/test_pipelines_cli.py | 171 ++++++++ 2 files changed, 525 insertions(+), 38 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index c78af00..6774909 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -16,6 +16,7 @@ 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 @@ -50,11 +51,36 @@ class HydratedPipeline: resolved_count: int -ComponentResolver = Callable[ - ["PipelineHydrator", Any, str, Path | None], - tuple[str, dict[str, Any]] | None, -] +@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 register_component_resolver(kind: str, resolver: ComponentResolver) -> None: @@ -76,7 +102,35 @@ def available_component_resolvers() -> list[str]: return sorted(COMPONENT_RESOLVERS) -def _available_resolvers_text(resolvers: Mapping[str, ComponentResolver]) -> str: +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)" @@ -139,8 +193,12 @@ def __init__( 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, trusted_python_sources: list[str] | None = None, allow_all_hydration: bool = False, + recursive_context: str | None = None, + error_policy: str = "warn", ) -> None: self.client = client self._client_options = { @@ -159,9 +217,18 @@ def __init__( 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.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() } @@ -176,9 +243,163 @@ def _api_client(self) -> TangleApiClient: ) return self.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) -> str: """Compute a cache key for a component reference.""" - return f"{ref_type}:{ref_value}" + key = f"{ref_type}:{ref_value}" + 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 _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 _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) + return reader(self, uri, hook_context) + + 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.""" @@ -206,7 +427,8 @@ def _resolve_registered_component( resolver = self.component_resolvers.get(kind) if resolver is None: raise self._unsupported_resolver(kind) - return resolver(self, value, path, base_dir) + context = self.make_resolver_context(kind, value, path, base_dir) + return self._call_component_resolver(resolver, value, path, base_dir, context) def fetch_component(self, digest: str) -> tuple[str, dict[str, Any]]: """Fetch a component, optionally following deprecation successors.""" @@ -291,6 +513,8 @@ def _fetch_component_by_url( ) -> tuple[str, dict[str, Any]] | None: """Fetch a component by URL and return as dict.""" scheme = url.split("://", 1)[0] if "://" in url else "url" + if scheme in self.uri_readers: + return self._fetch_component_from_uri(url, path, base_dir) return self._resolve_registered_component(scheme, url, path, base_dir) def fetch_remote_component( @@ -323,15 +547,44 @@ def fetch_remote_component( ) 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: + raise HydrationError(f"Failed to parse component YAML from {url}: {exc}") from exc + if not isinstance(spec, dict): + raise HydrationError(f"Component YAML at {url} must be a mapping") + if "template_file" in spec: + raise UnsupportedHydrationFeatureError( + f"template_file configs are not supported for non-local URI {url!r}" + ) + 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: - """Overridable hook for downstream GCS support; unsupported in OSS.""" + """Compatibility hook for downstream GCS support via URI readers.""" - raise self._unsupported_resolver("gs") + return self._fetch_component_from_uri(url, path, base_dir) def _render_template_config( self, @@ -350,8 +603,10 @@ def _render_template_config( 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, overrides=overrides) + 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}") @@ -389,10 +644,17 @@ def _fetch_component_from_file_url( raise HydrationError(f"Component file {path_obj} must contain a mapping") 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 + 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 @@ -419,34 +681,49 @@ def _fetch_component_by_resolve_url( file_path, fragment = file_path.rsplit("#", 1) self.log.info(f" Resolving component via config: {url}... ({path})") - if file_path.startswith("gs://"): - raise UnsupportedHydrationFeatureError( - "gs:// resolve configs are not supported by OSS hydrate" - ) - path_obj = Path(file_path) - if not path_obj.is_absolute() and base_dir: - path_obj = (base_dir / path_obj).resolve() + 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: - path_obj = path_obj.resolve() - if not path_obj.exists(): - self.log.warn(f" ⚠️ Resolve config not found: {path_obj}") - return None + 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: + raise HydrationError(f"Error reading resolve config {path_obj}: {exc}") from exc try: - text = path_obj.read_text(encoding="utf-8") 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 {path_obj}: unset variable {exc}") + self.log.warn(f" ⚠️ Resolve config {source}: unset variable {exc}") return None except Exception as exc: - raise HydrationError(f"Error parsing resolve config {path_obj}: {exc}") from exc + raise HydrationError(f"Error parsing resolve config {source}: {exc}") from exc 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 {path_obj}" + f" ⚠️ Fragment '{fragment}' not found in resolve config {source}" ) return None entry = config[fragment] @@ -456,7 +733,7 @@ def _fetch_component_by_resolve_url( else: config = entry - return self._resolve_from_config(config, path, path_obj.parent) + return self._resolve_from_config(config, path, nested_base_dir) def _resolve_from_config( self, @@ -538,9 +815,8 @@ def _resolve_local_side( return self._resolve_registered_component(kind, value, path, base_dir) return None - @staticmethod - def _resolve_entry_kinds() -> tuple[str, ...]: - return ( + def _resolve_entry_kinds(self) -> tuple[str, ...]: + builtin_kinds = ( "digest", "url", "name", @@ -553,6 +829,7 @@ def _resolve_entry_kinds() -> tuple[str, ...]: "from-docker", "from-container", ) + return tuple(dict.fromkeys((*builtin_kinds, *self.component_resolvers))) def _resolve_by_name_with_filters( self, @@ -722,8 +999,11 @@ def _resolve_task( 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 if not isinstance(task_data, dict): return task_data @@ -914,10 +1194,15 @@ def process_task( 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) + return self._resolve_task( + task_name, task_data, path, task_base_dir, recursive_params + ) pipeline_name = data.get("name", "pipeline") - return utils.traverse_pipeline_tasks(data, pipeline_name, process_task, base_dir) + 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: @@ -931,24 +1216,45 @@ def hydrate_file( overrides: dict[str, str] | None = None, ) -> HydratedPipeline: """Hydrate a pipeline YAML file.""" - input_path = input_file if isinstance(input_file, Path) else Path(str(input_file)) - base_dir = input_path.parent.resolve() + input_str = str(input_file) + input_scheme = self._uri_scheme(input_str) + input_path: Path | None = None + base_dir: Path | None = None try: - config = yaml.safe_load(input_path.read_text(encoding="utf-8")) + 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_path}: {exc}") from 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 @@ -957,9 +1263,19 @@ def hydrate_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_path = Path(output_file) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(output_content, encoding="utf-8") + 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) diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index ae4fe2f..e46527e 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -833,6 +833,177 @@ def fake_docker_resolver(hydrator, value, path, base_dir): ] +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_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_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" From 9d5fc9e2cd6389aeec664bc00103f79b4531a923 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 07:35:51 -0700 Subject: [PATCH 060/111] feat: add component publisher downstream seams --- .../src/tangle_cli/component_publisher.py | 159 ++++++++++++++++-- tests/test_component_publisher.py | 102 +++++++++++ tests/test_tangle_deploy_compat_imports.py | 2 + 3 files changed, 249 insertions(+), 14 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index 1d9998b..f9074f3 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -8,9 +8,10 @@ from __future__ import annotations +import inspect import os -from collections.abc import Mapping, Sequence -from dataclasses import dataclass +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 @@ -59,19 +60,58 @@ def to_dict(self) -> dict[str, Any]: 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 Slack summaries) without OSS importing or - knowing about those systems. + 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]]) -> None: ... + def before_batch( + self, + components_config: Sequence[Mapping[str, Any]], + context: ComponentPublishContext | None = None, + ) -> None: ... - def after_component(self, component_path: str, result: ProcessingResult) -> None: ... + def after_component( + self, + component_path: str, + result: ProcessingResult, + context: ComponentPublishContext | None = None, + ) -> None: ... - def after_batch(self, results: Sequence[tuple[str, ProcessingResult]]) -> None: ... + def after_batch( + self, + results: Sequence[tuple[str, ProcessingResult]], + context: ComponentPublishContext | None = None, + ) -> None: ... # ============================================================================ @@ -239,20 +279,26 @@ def __init__( 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, ) -> None: """Initialize the ComponentPublisher. Args mirror the generic ``tangle-deploy`` publisher shape, with - Shopify/Slack-specific fields intentionally omitted. + Shopify/Slack-specific 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. """ self.dry_run = dry_run self._client = client + self._client_factory = client_factory self.published_by = published_by self.hooks = list(hooks or []) self.log = logger or get_default_logger() @@ -263,11 +309,20 @@ def __init__( 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 _get_client(self) -> Any | None: - """Get or create a TangleApiClient instance.""" + """Get or create a Tangle API client instance. + + Downstream packages can either pass ``client_factory`` to the + constructor or subclass and override this method to provide custom auth + and lazy client construction. + """ if self._client is None and not self.dry_run: + if self._client_factory is not None: + self._client = self._client_factory() + return self._client try: from .client import TangleApiClient except ModuleNotFoundError as exc: @@ -483,7 +538,8 @@ def publish_components(self, components_config: list[dict[str, Any]]) -> int: self.log.info(f"📤 Publishing {len(components_config)} component(s) to Tangle API") self.log.info("=" * 60) - self._run_hook("before_batch", components_config) + 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: @@ -502,7 +558,18 @@ def publish_components(self, components_config: list[dict[str, Any]]) -> int: reason="Missing 'component_path' in configuration", ) all_results.append(("", error_result)) - self._run_hook("after_component", "", 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 @@ -535,7 +602,18 @@ def publish_components(self, components_config: list[dict[str, Any]]) -> int: ) self.log.error(f" ❌ Unexpected error: {exc}") all_results.append((str(component_path), result)) - self._run_hook("after_component", 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) @@ -557,7 +635,11 @@ def publish_components(self, components_config: list[dict[str, Any]]) -> int: self.log.error(f" • {component_name}: {result.reason}") self.results = all_results - self._run_hook("after_batch", 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") @@ -570,10 +652,39 @@ def publish_components(self, components_config: list[dict[str, Any]]) -> int: return 1 return 0 - def _run_hook(self, method_name: str, *args: Any) -> None: + 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 method: + if not method: + continue + if context is not None and _hook_accepts_context(method): + method(*args, context=context) + else: method(*args) @@ -588,11 +699,13 @@ def publish_component_to_tangle( 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``.""" @@ -602,7 +715,9 @@ def publish_component_to_tangle( git_remote_sha=git_remote_sha, git_remote_branch=git_remote_branch, git_remote_url=git_remote_url, + git_repo=git_repo, client=client, + client_factory=client_factory, published_by=published_by, ) return publisher.publish_component( @@ -623,8 +738,10 @@ def publish_component(client: Any, component_path: str | Path, **kwargs: Any) -> 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) @@ -693,6 +810,19 @@ def prepare_component_for_publish( # ============================================================================ +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 _component_spec_from_yaml( yaml_content: str, *, @@ -747,6 +877,7 @@ def _to_plain(value: Any) -> Any: __all__ = [ + "ComponentPublishContext", "ComponentPublishHook", "ComponentPublisher", "ProcessingOutcome", diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py index 17130a1..2b9ebc3 100644 --- a/tests/test_component_publisher.py +++ b/tests/test_component_publisher.py @@ -9,8 +9,10 @@ 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, @@ -106,6 +108,39 @@ def test_publish_yaml_parsing_error(tmp_path: Path) -> None: 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") @@ -294,6 +329,73 @@ 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/Shopify/discovery", + git_repo="Shopify/discovery", + 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/Shopify/discovery" + assert before_context.git_repo == "Shopify/discovery" + 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") diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 9479d7c..4c6e7a7 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -14,6 +14,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: 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, @@ -126,6 +127,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: 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) From 26b4de8043b7141ed1087a40471c32d77c353d49 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 07:36:35 -0700 Subject: [PATCH 061/111] fix: scope recursive hydration params per run --- .../src/tangle_cli/pipeline_hydrator.py | 124 +++++++++--------- tests/test_pipelines_cli.py | 75 +++++++++++ 2 files changed, 140 insertions(+), 59 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 6774909..0e3fbae 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -1004,6 +1004,8 @@ def _resolve_task( """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 @@ -1216,67 +1218,71 @@ def hydrate_file( overrides: dict[str, str] | None = None, ) -> HydratedPipeline: """Hydrate a pipeline YAML file.""" - input_str = str(input_file) - input_scheme = self._uri_scheme(input_str) - input_path: Path | None = None - base_dir: Path | None = None + self._global_params = {} 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), - ) + 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: - 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) + 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 = {} # ============================================================================= diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index e46527e..0d1208c 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -1004,6 +1004,81 @@ def test_pipeline_hydrator_recursive_context_flows_to_child_templates(tmp_path: 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" From 8a32735d2b8482e4f312a883cf00976e2ca67446 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 07:49:23 -0700 Subject: [PATCH 062/111] feat: add pipeline run lifecycle seams --- .../src/tangle_cli/pipeline_runs.py | 597 +++++++++++++++++- tests/test_pipeline_runs_cli.py | 347 +++++++++- 2 files changed, 916 insertions(+), 28 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index c1b29c3..d1e6f5e 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -11,7 +11,9 @@ import copy import json +import re import time +from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass, field from pathlib import Path from typing import Any, Mapping @@ -34,6 +36,41 @@ class UnsupportedPipelineRunFeatureError(PipelineRunError): """Raised for TD extension points intentionally unsupported in OSS defaults.""" +@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. + """ + + 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 + 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 + + @dataclass class PipelineRunHooks: """Overridable seams for downstream tangle-deploy behavior. @@ -80,6 +117,23 @@ def hydrate_pipeline( 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], @@ -88,6 +142,17 @@ def prepare_run_arguments( """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, *, @@ -103,13 +168,124 @@ def extra_submit_annotations( return {} def before_submit(self, pipeline_spec: dict[str, Any]) -> None: - """Hook for TD mutex/overlap checks.""" + """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: - """Hook for downstream start notifications.""" + """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.""" + + return False + + 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: - """Hook for downstream success/failure notifications.""" + """Legacy hook retained for terminal downstream notifications.""" + + 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 fetch_logs(self, client: Any, execution_id: str) -> Any: """Hook for alternate TD log providers; OSS uses the Tangle API only.""" @@ -221,6 +397,48 @@ def sanitize_submit_payload(value: Any) -> Any: 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 status_counts_from_graph_state(graph_state: Mapping[str, Any]) -> dict[str, int]: + for key in ("status_totals", "execution_status_stats"): + stats = graph_state.get(key) + if isinstance(stats, Mapping): + return { + str(status).upper(): int(count or 0) + for status, count in stats.items() + } + child_stats = graph_state.get("child_execution_status_stats") + totals: dict[str, int] = {} + if isinstance(child_stats, Mapping): + for stats in child_stats.values(): + if not isinstance(stats, Mapping): + continue + for status, count in stats.items(): + totals[str(status).upper()] = totals.get(str(status).upper(), 0) + int(count or 0) + return totals + + @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") @@ -258,6 +476,88 @@ def load_pipeline_for_submit( ) 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 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.""" + + prepared_spec = self.prepare_pipeline_spec_for_submit( + pipeline_spec, + pipeline_path=pipeline_path, + run_args=run_args, + hydrate=hydrate, + ) + run_args = self.hooks.prepare_run_arguments(prepared_spec, run_args) + prepared_spec = self.apply_run_name_template(prepared_spec, run_args) + payload = self.convert_yaml_to_payload(copy.deepcopy(prepared_spec), run_args) + payload = self.sanitize_submit_payload(payload) + 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()}) + return {"root_task": payload["root_task"], "annotations": submit_annotations} + def build_submit_body( self, pipeline_path: str | Path, @@ -273,17 +573,94 @@ def build_submit_body( hydrate=hydrate, resolution_overrides=resolution_overrides, ) - run_args = self.hooks.prepare_run_arguments(pipeline_spec, run_args) - payload = self.convert_yaml_to_payload(copy.deepcopy(pipeline_spec), run_args) - payload = self.sanitize_submit_payload(payload) - submit_annotations = self.hooks.extra_submit_annotations( + return self.build_submit_body_from_spec( + pipeline_spec, + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=hydrate, + ) + + @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=str(run_name) if run_name 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 + ), + 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, + ) -> dict[str, Any]: + pipeline_spec = body["root_task"]["componentRef"]["spec"] + context = PipelineRunContext( + run_name=str(pipeline_spec.get("name")) if isinstance(pipeline_spec, dict) else None, + pipeline_path=pipeline_path, + start_time=time.time(), + attempt=attempt, + submit_body=body, pipeline_spec=pipeline_spec, + ) + self.hooks.before_submit_context(context) + try: + response = self.to_plain(self.client.pipeline_runs_create(body=body)) + except Exception as exc: + self.hooks.on_submit_error(exc, context=context) + raise + if not isinstance(response, dict): + response = {} + submitted_context = self.response_run_context( + response, + submit_body=body, + pipeline_path=pipeline_path, + attempt=attempt, + ) + self.hooks.after_submit_context(submitted_context) + 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]: + body = self.build_submit_body_from_spec( + pipeline_spec, + run_args=run_args, + annotations=annotations, pipeline_path=pipeline_path, run_as=run_as, + hydrate=hydrate, ) - if annotations: - submit_annotations.update({str(k): str(v) for k, v in annotations.items()}) - return {"root_task": payload["root_task"], "annotations": submit_annotations} + return self.submit_prepared_body(body, pipeline_path=pipeline_path, attempt=attempt) def submit_pipeline( self, @@ -294,6 +671,7 @@ def submit_pipeline( hydrate: bool = True, run_as: str | None = None, resolution_overrides: dict[str, Any] | None = None, + attempt: int = 1, ) -> dict[str, Any]: body = self.build_submit_body( pipeline_path, @@ -303,11 +681,7 @@ def submit_pipeline( run_as=run_as, resolution_overrides=resolution_overrides, ) - pipeline_spec = body["root_task"]["componentRef"]["spec"] - self.hooks.before_submit(pipeline_spec) - response = self.to_plain(self.client.pipeline_runs_create(body=body)) - self.hooks.after_submit(response) - return response + return self.submit_prepared_body(body, 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( @@ -393,29 +767,198 @@ def export_run(self, run_id: str, output: str | Path | None = None) -> dict[str, output_path.write_text(content, encoding="utf-8") return {"run_id": run_id, "output": str(output_path)} + def _poll_run_status( + self, + run_id: str, + *, + use_graph_state: bool, + started_at: float, + ) -> PipelineWaitPoll: + run = self.get_run(run_id, include_execution_stats=True) + graph_state: dict[str, Any] | None = None + status_counts = self.status_counts_from_run(run) + if use_graph_state: + root_execution_id = run.get("root_execution_id") + 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 + status = self.status_from_counts(status_counts) or self.status_from_run(run) or "UNKNOWN" + terminal = self.is_terminal_status(status) or status == "ENDED" + return PipelineWaitPoll( + run_id=run_id, + run=run, + status=status, + status_counts=status_counts, + total=sum(status_counts.values()), + terminal=terminal, + graph_state=graph_state, + elapsed_seconds=time.monotonic() - started_at, + ) + def wait_for_completion( self, run_id: str, *, - max_wait: float, + max_wait: float | None, poll_interval: float, + use_graph_state: bool = False, + context: PipelineRunContext | None = None, ) -> dict[str, Any]: - if max_wait < 0: + wait_context = context or PipelineRunContext(run_id=run_id, start_time=time.time()) + if max_wait is not None and max_wait < 0: raise PipelineRunError("--max-wait must be non-negative") if poll_interval <= 0: raise PipelineRunError("--poll-interval must be positive") - deadline = time.monotonic() + max_wait - last_run: dict[str, Any] = {} + enforce_max_wait = max_wait is not None and self.hooks.should_enforce_max_wait(wait_context) + started_at = time.monotonic() + deadline = started_at + max_wait if enforce_max_wait else None + self.hooks.before_wait(wait_context) + last_poll: PipelineWaitPoll | None = None while True: - last_run = self.get_run(run_id, include_execution_stats=True) - status = self.status_from_run(last_run) - if self.is_terminal_status(status) or status == "ENDED": - result = {"run": last_run, "status": status or "ENDED", "timed_out": False} - self.hooks.after_wait(result) + poll = self._poll_run_status(run_id, use_graph_state=use_graph_state, started_at=started_at) + last_poll = poll + self.hooks.after_poll(poll, wait_context) + if poll.terminal: + self.hooks.on_terminal(poll, wait_context) + result = {"run": poll.run, "status": poll.status, "timed_out": False} + self.hooks.after_wait_context(result, wait_context) + return result + if self.hooks.should_exit_early(poll, wait_context): + self.hooks.on_early_exit_before_release(poll, wait_context) + result = {"run": poll.run, "status": poll.status, "timed_out": False, "early_exit": True} + self.hooks.after_wait_context(result, wait_context) return result - if time.monotonic() >= deadline: - return {"run": last_run, "status": status or "UNKNOWN", "timed_out": True} - time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic()))) + if deadline is not None and time.monotonic() >= deadline: + self.hooks.on_timeout(poll, wait_context) + result = {"run": poll.run, "status": poll.status, "timed_out": True} + 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 - time.monotonic())) + 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}") + + 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, + ) -> dict[str, Any]: + """Submit (and optionally wait for) a pipeline with lifecycle hooks. + + This is intentionally opt-in so existing CLI submit/wait semantics stay + unchanged. Downstreams can use it to centralize mutex acquisition, + graceful-shutdown context, retry, and notification lifecycles around the + generic OSS submit/wait behavior. + """ + + if max_attempts < 1: + raise PipelineRunError("max_attempts must be at least 1") + last_error: Exception | None = None + previous_context: PipelineRunContext | None = None + for attempt in range(1, max_attempts + 1): + context = PipelineRunContext( + pipeline_path=pipeline_path, + start_time=time.time(), + attempt=attempt, + ) + lifecycle_started = False + success = False + error: Exception | None = None + retry_requested = False + body = self.build_submit_body( + pipeline_path, + run_args=run_args, + annotations=annotations, + hydrate=hydrate, + run_as=run_as, + resolution_overrides=resolution_overrides, + ) + 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 and context.pipeline_spec.get("name") is not None: + context.run_name = str(context.pipeline_spec["name"]) + self.hooks.before_run_lifecycle(context) + lifecycle_started = True + try: + with self.hooks.around_run(context): + try: + response = self.submit_prepared_body( + body, + pipeline_path=pipeline_path, + attempt=attempt, + ) + submitted_context = self.response_run_context( + response, + 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 + previous_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, + ) + result = {"response": response, "wait": wait_result} + else: + result = {"response": response} + success = True + return result + except Exception as exc: + error = exc + last_error = exc + 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 parse_key_value_entries(entries: list[str] | None) -> dict[str, str]: diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index c40843c..64c0502 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from contextlib import nullcontext from pathlib import Path from types import SimpleNamespace from typing import Any @@ -9,7 +10,7 @@ import yaml from tangle_cli import cli, pipeline_runs_cli -from tangle_cli.pipeline_runs import PipelineRunManager, PipelineRunError +from tangle_cli.pipeline_runs import PipelineRunHooks, PipelineRunManager, PipelineRunError def run_app(app, args: list[str]) -> None: @@ -599,6 +600,350 @@ def fake_regenerate_yaml(**kwargs): 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_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_run_status_uses_deterministic_precedence() -> None: run = { "execution_status_stats": { From 7e3805d50cfa74e9e32745b0c8237ae086c47178 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 07:57:18 -0700 Subject: [PATCH 063/111] feat: add pipeline run search and details helpers --- .../src/tangle_cli/pipeline_run_details.py | 182 ++++++ .../src/tangle_cli/pipeline_run_search.py | 565 ++++++++++++++++++ .../src/tangle_cli/pipeline_runs.py | 47 +- .../src/tangle_cli/pipeline_runs_cli.py | 102 +++- tests/test_pipeline_runs_cli.py | 135 +++++ 5 files changed, 1013 insertions(+), 18 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_run_details.py create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_run_search.py 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..85c43fa --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py @@ -0,0 +1,182 @@ +"""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 +Observe/GCP/Slack 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 + + +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 serialize_execution(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 transparency_check + + transparent, reason = 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"] = _to_plain(implementation) + arguments = _value(task_spec, "arguments") + if arguments: + out["arguments"] = _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"] = _to_plain(input_artifacts) + output_artifacts = _value(execution, "output_artifacts") + if output_artifacts: + out["output_artifacts"] = _to_plain(output_artifacts) + return out + + +def serialize_run_details(details: Any) -> dict[str, Any]: + """Convert ``RunDetails`` into a JSON-serializable dict.""" + + if isinstance(details, dict): + return _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"] = _to_plain(annotations) + if details.execution: + out["execution"] = serialize_execution(details.execution) + if details.annotations: + out["annotations"] = _to_plain(details.annotations) + if details.execution_state: + out["execution_state"] = { + "totals": _to_plain(_value(details.execution_state, "status_totals")), + "per_execution": _to_plain(_value(details.execution_state, "child_execution_status_stats")), + } + return out + + +def get_run_details_output( + client: Any, + 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 = client.get_run_details(run_id, **kwargs) + return serialize_run_details(details) + + +def _status_code(exc: Exception) -> int | None: + response = getattr(exc, "response", None) + return getattr(response, "status_code", None) + + +def fetch_graph_state_one(client: Any, run_id: str) -> dict[str, Any]: + """Fetch graph state for a pipeline run id or root execution id.""" + + try: + run = client.pipeline_runs_get(run_id) + except Exception as exc: + if _status_code(exc) != 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 = client.executions_graph_execution_state(root_execution_id) + return { + "run_id": run_id, + "root_execution_id": root_execution_id, + "status_totals": _to_plain(_value(state, "status_totals")), + "failed_execution_ids": _to_plain(_value(state, "failed_execution_ids")), + "per_execution": _to_plain(_value(state, "per_execution")), + "error": None, + } + + +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, + } + + +def get_graph_state_output( + client: Any, + 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(fetch_graph_state_one, client, 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} 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..889c352 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -0,0 +1,565 @@ +"""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 +wrap these helpers with Shopify auth and legacy Typer entry points. +""" + +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 .logger import Logger, get_default_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 + + +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, + } + + +def search_pipeline_runs( + *, + client: Any, + 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, + logger: Logger | None = None, +) -> dict[str, Any]: + """Search pipeline runs and return rows, page metadata, and tables.""" + + log = logger or get_default_logger() + limit = max(1, min(limit, 100)) + resolved_created_by, err = resolve_created_by(created_by=created_by, client=client, logger=log) + if err is not None: + return err + resolved_start, resolved_end = resolve_dates( + start_date=start_date, + end_date=end_date, + local_time=local_time, + logger=log, + ) + filter_query_dict = query or 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 + log.info(f"Searching pipeline runs (limit={limit})...") + base_url = getattr(client, "base_url", "").rstrip("/") + all_rows, page_chunks, final_next_token = fetch_pipeline_run_search_pages( + client=client, + 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: + log.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 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 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, + ) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index d1e6f5e..6ecbcee 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -22,6 +22,8 @@ from .logger import Logger, get_default_logger from .pipeline_hydrator import HydrationError, PipelineHydrator +from .pipeline_run_details import get_graph_state_output, get_run_details_output +from .pipeline_run_search import search_pipeline_runs from .utils import dump_yaml _TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED") @@ -697,13 +699,16 @@ def get_run_details( *, include_annotations: bool = False, include_execution_state: bool = False, + include_implementations: bool = False, + execution_id: str | None = None, ) -> dict[str, Any]: - return self.to_plain( - self.client.get_run_details( - run_id, - include_annotations=include_annotations, - include_execution_state=include_execution_state, - ) + return get_run_details_output( + self.client, + 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]: @@ -712,6 +717,9 @@ def cancel_run(self, run_id: str) -> dict[str, Any]: def graph_state(self, execution_id: str) -> dict[str, Any]: return self.to_plain(self.client.executions_graph_execution_state(execution_id)) + def graph_state_output(self, run_ids: list[str], *, timeout: float = 30.0) -> dict[str, Any]: + return get_graph_state_output(self.client, 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)) @@ -734,6 +742,33 @@ def search_runs( ) ) + 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 search_pipeline_runs( + client=self.client, + 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, + logger=self.logger, + ) + def annotations_list(self, run_id: str) -> dict[str, Any]: return self.to_plain(self.client.pipeline_runs_annotations(run_id)) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index d6d4edd..4b0544d 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import pathlib from typing import Annotated, Any @@ -25,6 +26,7 @@ TokenOption, ) from .logger import Logger, logger_for_log_type +from .pipeline_run_search import normalize_query_input, parse_annotation from .pipeline_runs import ( PipelineRunError, PipelineRunHooks, @@ -180,6 +182,8 @@ def action(manager: PipelineRunManager, args: ArgsContainer) -> dict[str, Any]: 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, @@ -192,6 +196,8 @@ def pipeline_runs_details( """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"), @@ -205,6 +211,8 @@ def pipeline_runs_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, ), ) @@ -340,9 +348,25 @@ 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, @@ -350,28 +374,82 @@ def pipeline_runs_search( config: ConfigOption = None, log_type: LogTypeOption = "console", ) -> None: - """Search/list pipeline runs using the Tangle API filters.""" + """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), } - _run_manager_action( - config, - base_url, - specs, - lambda manager, args: manager.search_runs( - filter=args.query, - filter_query=args.filter_query, + + 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, - include_pipeline_names=args.include_pipeline_names, - include_execution_stats=args.include_execution_stats, - ), - ) + ) + 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") diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 64c0502..2307740 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -41,6 +41,7 @@ def _write_pipeline(path: Path) -> 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]] = [] @@ -78,6 +79,9 @@ 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} @@ -473,6 +477,137 @@ def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: assert yaml.safe_load(output.read_text(encoding="utf-8"))["name"] == "Exported" +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_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, From 7a1c2eb0e808c1b7090840fcd936355173274734 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 09:39:46 -0700 Subject: [PATCH 064/111] feat: add upstream pipeline dehydrator --- .../src/tangle_cli/pipeline_dehydrator.py | 700 ++++++++++++++++++ tests/test_pipeline_dehydrator.py | 169 +++++ tests/test_tangle_deploy_compat_imports.py | 8 + 3 files changed, 877 insertions(+) create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py create mode 100644 tests/test_pipeline_dehydrator.py 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..443a2a5 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py @@ -0,0 +1,700 @@ +"""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 .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: + """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, + *, + uri_readers: Mapping[str, UriReader] | None = None, + uri_writers: Mapping[str, UriWriter] | None = None, + ) -> None: + self.remembered_choices = dict(remembered_choices or {}) + self.output_file = output_file + self.log = logger or get_default_logger() + 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.client = client + 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, + 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" + if self.client is not None: + try: + self.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" + self.log.info(" Auto: no API client provided -> 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 = Path(str(destination)[7:] if str(destination).startswith("file://") else str(destination)).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_path = Path(str(destination)[7:] if str(destination).startswith("file://") else str(destination)) + 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_path = Path(str(self.output_file)[7:] if str(self.output_file).startswith("file://") else str(self.output_file)) + 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/tests/test_pipeline_dehydrator.py b/tests/test_pipeline_dehydrator.py new file mode 100644 index 0000000..f6f60d0 --- /dev/null +++ b/tests/test_pipeline_dehydrator.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +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_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_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 4c6e7a7..52c3325 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -47,6 +47,11 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: search_components, transparency_check, ) + from tangle_cli.pipeline_dehydrator import ( + DehydrateChoice, + Jinja2ExportResult, + PipelineDehydrator, + ) from tangle_cli.logger import ( CaptureLogger, CliLogType, @@ -152,6 +157,9 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert callable(inspect_by_name) assert callable(search_components) assert callable(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 From 13c278a715b41152810e7703fa4dc00a37f78e75 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 09:39:34 -0700 Subject: [PATCH 065/111] feat: add pipeline run spec lifecycle seam --- .../src/tangle_cli/pipeline_runs.py | 242 ++++++++++++++---- tests/test_pipeline_runs_cli.py | 161 ++++++++++++ 2 files changed, 356 insertions(+), 47 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 6ecbcee..bfee76c 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -13,6 +13,7 @@ import json import re import time +from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass, field from pathlib import Path @@ -616,21 +617,26 @@ def submit_prepared_body( *, pipeline_path: str | Path | None = None, attempt: int = 1, + context: PipelineRunContext | None = None, ) -> dict[str, Any]: pipeline_spec = body["root_task"]["componentRef"]["spec"] - context = PipelineRunContext( - run_name=str(pipeline_spec.get("name")) if isinstance(pipeline_spec, dict) else None, + submit_context = context or PipelineRunContext( pipeline_path=pipeline_path, start_time=time.time(), attempt=attempt, - submit_body=body, - pipeline_spec=pipeline_spec, ) - self.hooks.before_submit_context(context) + submit_context.run_name = ( + str(pipeline_spec.get("name")) if isinstance(pipeline_spec, dict) 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) try: response = self.to_plain(self.client.pipeline_runs_create(body=body)) except Exception as exc: - self.hooks.on_submit_error(exc, context=context) + self.hooks.on_submit_error(exc, context=submit_context) raise if not isinstance(response, dict): response = {} @@ -640,7 +646,13 @@ def submit_prepared_body( pipeline_path=pipeline_path, attempt=attempt, ) - self.hooks.after_submit_context(submitted_context) + 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_pipeline_spec( @@ -840,19 +852,29 @@ def wait_for_completion( poll_interval: float, use_graph_state: bool = False, context: PipelineRunContext | None = None, + allow_zero_poll_interval: bool = False, + timeout_clock: str = "monotonic", ) -> dict[str, Any]: wait_context = context or PipelineRunContext(run_id=run_id, start_time=time.time()) if max_wait is not None and max_wait < 0: raise PipelineRunError("--max-wait must be non-negative") - if poll_interval <= 0: + 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) - started_at = time.monotonic() - deadline = started_at + max_wait if enforce_max_wait else None + 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: - poll = self._poll_run_status(run_id, use_graph_state=use_graph_state, started_at=started_at) + poll = self._poll_run_status( + run_id, + use_graph_state=use_graph_state, + started_at=poll_started_at, + ) last_poll = poll self.hooks.after_poll(poll, wait_context) if poll.terminal: @@ -865,7 +887,7 @@ def wait_for_completion( result = {"run": poll.run, "status": poll.status, "timed_out": False, "early_exit": True} self.hooks.after_wait_context(result, wait_context) return result - if deadline is not None and time.monotonic() >= deadline: + if deadline is not None and deadline_now() >= deadline: self.hooks.on_timeout(poll, wait_context) result = {"run": poll.run, "status": poll.status, "timed_out": True} self.hooks.after_wait_context(result, wait_context) @@ -873,56 +895,44 @@ def wait_for_completion( if deadline is None: sleep_for = poll_interval else: - sleep_for = min(poll_interval, max(0.0, deadline - time.monotonic())) + 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}") - def run_pipeline( + def _run_body_factory( self, - pipeline_path: str | Path, + body_factory: Callable[[int, PipelineRunContext | None, Exception | None], dict[str, Any]], *, - 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, + 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", + metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Submit (and optionally wait for) a pipeline with lifecycle hooks. - - This is intentionally opt-in so existing CLI submit/wait semantics stay - unchanged. Downstreams can use it to centralize mutex acquisition, - graceful-shutdown context, retry, and notification lifecycles around the - generic OSS submit/wait behavior. - """ + """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, + metadata=dict(metadata or {}), ) lifecycle_started = False success = False error: Exception | None = None retry_requested = False - body = self.build_submit_body( - pipeline_path, - run_args=run_args, - annotations=annotations, - hydrate=hydrate, - run_as=run_as, - resolution_overrides=resolution_overrides, - ) + body = body_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 @@ -930,6 +940,7 @@ def run_pipeline( context.run_name = str(context.pipeline_spec["name"]) self.hooks.before_run_lifecycle(context) lifecycle_started = True + attempts.append(context) try: with self.hooks.around_run(context): try: @@ -937,19 +948,8 @@ def run_pipeline( body, pipeline_path=pipeline_path, attempt=attempt, + context=context, ) - submitted_context = self.response_run_context( - response, - 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 previous_context = context if attempt > 1: self.hooks.after_retry_submit(context) @@ -961,10 +961,14 @@ def run_pipeline( poll_interval=poll_interval, use_graph_state=use_graph_state, context=context, + allow_zero_poll_interval=allow_zero_poll_interval, + timeout_clock=timeout_clock, ) result = {"response": response, "wait": wait_result} else: result = {"response": response} + result["context"] = context + result["attempts"] = attempts success = True return result except Exception as exc: @@ -995,6 +999,150 @@ def run_pipeline( 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", + 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, + 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", + 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.build_submit_body_from_spec( + copy.deepcopy(pipeline_spec), + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=hydrate, + ) + + 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, + 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", + 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.build_submit_body( + pipeline_path, + run_args=run_args, + annotations=annotations, + hydrate=hydrate, + run_as=run_as, + resolution_overrides=resolution_overrides, + ) + + 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, + metadata=metadata, + ) + def parse_key_value_entries(entries: list[str] | None) -> dict[str, str]: parsed: dict[str, str] = {} diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 2307740..57a0f65 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json from contextlib import nullcontext from pathlib import Path @@ -1079,6 +1080,166 @@ def after_run_lifecycle(self, context, *, success, error=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_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 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_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": { From 6d1a376b456752e09ac6c03e72b3bd5a46ea900b Mon Sep 17 00:00:00 2001 From: Volv G <124614463+Volv-G@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:48:32 -0700 Subject: [PATCH 066/111] feat(tangle-cli): add generic resolver and poll seams Adds generic OSS seams used by downstream tangle-deploy so TD can stay a thin extension layer.\n\nValidation:\n- uv run pytest -q (566 passed)\n- targeted ruff on touched files --- .../src/tangle_cli/pipeline_hydrator.py | 149 ++++++++++++++++-- .../src/tangle_cli/pipeline_runs.py | 124 ++++++++++++--- tests/test_pipeline_runs_cli.py | 73 ++++++++- tests/test_pipelines_cli.py | 57 +++++++ 4 files changed, 366 insertions(+), 37 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 0e3fbae..da31493 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -34,7 +34,7 @@ from .models import ComponentInfo -class HydrationError(RuntimeError): +class HydrationError(ValueError): """Raised when a pipeline cannot be hydrated safely in OSS mode.""" @@ -761,18 +761,41 @@ def _try_resolve_entry( ) -> tuple[str, dict[str, Any]] | None: """Try to resolve a single resolve-config entry. - Ported from TD, with resolver dispatch made registry-backed. + 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_result = self._resolve_local_side(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(" Resolve: primary source failed, using local source") + 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( @@ -801,18 +824,39 @@ def _resolve_primary( ) 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: - for kind in self._resolve_entry_kinds(): - if kind not in entry or kind in {"digest", "url", "name"}: - continue - value = entry[kind] - if value: - return self._resolve_registered_component(kind, value, path, base_dir) + 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, ...]: @@ -831,6 +875,72 @@ def _resolve_entry_kinds(self) -> tuple[str, ...]: ) 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], @@ -886,10 +996,19 @@ def _filter_by_annotations( if not candidate.digest: continue try: - spec = self._api_client().get_component_spec(candidate.digest).data - except Exception: - continue - annotations = spec.get("metadata", {}).get("annotations", {}) + 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 + spec_data = getattr(spec_obj, "data", spec_obj) + annotations = {} + if isinstance(spec_data, dict): + annotations = spec_data.get("metadata", {}).get("annotations", {}) or {} + attr_annotations = getattr(spec_obj, "annotations", None) + if attr_annotations: + annotations = attr_annotations if _annotations_match(annotations, required_annotations): result.append(candidate) return result @@ -908,6 +1027,8 @@ def _resolve_local_file( 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}") diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index bfee76c..afa2653 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -290,6 +290,39 @@ def should_enforce_max_wait(self, context: PipelineRunContext) -> bool: 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) @@ -303,12 +336,12 @@ class PipelineRunManager: @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, dict): - return {key: PipelineRunManager.to_plain(val) for key, val in value.items()} if isinstance(value, list): return [PipelineRunManager.to_plain(item) for item in value] return value @@ -414,21 +447,36 @@ def status_counts_from_run(run: Mapping[str, Any]) -> dict[str, int]: return result @staticmethod - def status_counts_from_graph_state(graph_state: Mapping[str, Any]) -> dict[str, int]: + 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(stats, Mapping): + 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 stats.items() + for status, count in counts.items() } - child_stats = graph_state.get("child_execution_status_stats") + 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] = {} - if isinstance(child_stats, Mapping): - for stats in child_stats.values(): - if not isinstance(stats, Mapping): + 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 stats.items(): + for status, count in counts.items(): totals[str(status).upper()] = totals.get(str(status).upper(), 0) + int(count or 0) return totals @@ -726,8 +774,15 @@ def get_run_details( 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) -> dict[str, Any]: - return self.to_plain(self.client.executions_graph_execution_state(execution_id)) + def graph_state(self, execution_id: str) -> Mapping[str, Any] | Any: + graph_state = self.client.executions_graph_execution_state(execution_id) + if not isinstance(graph_state, Mapping) and ( + hasattr(graph_state, "status_totals") + or hasattr(graph_state, "execution_status_stats") + or hasattr(graph_state, "child_execution_status_stats") + ): + return graph_state + return self.to_plain(graph_state) def graph_state_output(self, run_ids: list[str], *, timeout: float = 30.0) -> dict[str, Any]: return get_graph_state_output(self.client, run_ids, timeout=timeout) @@ -820,12 +875,19 @@ def _poll_run_status( *, use_graph_state: bool, started_at: float, + context: PipelineRunContext | None = None, ) -> PipelineWaitPoll: - run = self.get_run(run_id, include_execution_stats=True) + 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 status_counts = self.status_counts_from_run(run) if use_graph_state: - root_execution_id = run.get("root_execution_id") + 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) @@ -833,14 +895,22 @@ def _poll_run_status( status_counts = graph_counts 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 + if state != "CANCELED" + ) + terminal = terminal_count == total return PipelineWaitPoll( run_id=run_id, run=run, status=status, status_counts=status_counts, - total=sum(status_counts.values()), + total=total, terminal=terminal, - graph_state=graph_state, + graph_state=graph_state if isinstance(graph_state, dict) else None, elapsed_seconds=time.monotonic() - started_at, ) @@ -870,11 +940,21 @@ def wait_for_completion( self.hooks.before_wait(wait_context) last_poll: PipelineWaitPoll | None = None while True: - poll = self._poll_run_status( - run_id, - use_graph_state=use_graph_state, - started_at=poll_started_at, - ) + 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: + retry_interval = self.hooks.on_poll_error(exc, wait_context) + if retry_interval is None: + raise + time.sleep(retry_interval) + continue last_poll = poll self.hooks.after_poll(poll, wait_context) if poll.terminal: diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 57a0f65..2f5c581 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -11,7 +11,7 @@ import yaml from tangle_cli import cli, pipeline_runs_cli -from tangle_cli.pipeline_runs import PipelineRunHooks, PipelineRunManager, PipelineRunError +from tangle_cli.pipeline_runs import PipelineRunContext, PipelineRunHooks, PipelineRunManager, PipelineRunError def run_app(app, args: list[str]) -> None: @@ -835,6 +835,77 @@ def after_wait_context(self, result, context): ] +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_fail_fast_hook_runs_before_lifecycle_release(tmp_path: Path) -> None: pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") events = [] diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 0d1208c..cc8ec26 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -4,11 +4,13 @@ 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: @@ -84,6 +86,61 @@ def test_sdk_pipelines_help_lists_local_commands(capsys): 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() From bcd0dccd80ba6bccd07c68166f192b56047a53ee Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 13 Jun 2026 23:59:07 -0700 Subject: [PATCH 067/111] fix(tangle-cli): honor wait deadlines for graph polling --- .../src/tangle_cli/pipeline_runs.py | 15 +++-- tests/test_pipeline_runs_cli.py | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index afa2653..8093e1b 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -897,11 +897,7 @@ def _poll_run_status( 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 - if state != "CANCELED" - ) + terminal_count = sum(status_counts.get(state, 0) for state in _TERMINAL_STATUSES) terminal = terminal_count == total return PipelineWaitPoll( run_id=run_id, @@ -950,10 +946,17 @@ def wait_for_completion( 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 - time.sleep(retry_interval) + 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) diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 2f5c581..fec1a97 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -835,6 +835,38 @@ def after_wait_context(self, result, context): ] +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_graph_state_counts_supports_mapping_like_objects() -> None: assert PipelineRunManager.status_counts_from_graph_state( SimpleNamespace(status_totals={"SUCCEEDED": 1}) @@ -906,6 +938,31 @@ def on_poll_error(self, error, context): 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 = [] From 53c02e65817578ca1bea68a596f1a548cd923152 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 05:38:31 -0700 Subject: [PATCH 068/111] feat: add resource manager classes --- .../tangle-cli/src/tangle_cli/artifacts.py | 337 +++++++++++------- .../src/tangle_cli/pipeline_run_details.py | 302 ++++++++++------ .../src/tangle_cli/pipeline_run_search.py | 281 ++++++++++++--- packages/tangle-cli/src/tangle_cli/secrets.py | 184 +++++++--- tests/test_resource_managers.py | 258 ++++++++++++++ tests/test_tangle_deploy_compat_imports.py | 10 + 6 files changed, 1047 insertions(+), 325 deletions(-) create mode 100644 tests/test_resource_managers.py diff --git a/packages/tangle-cli/src/tangle_cli/artifacts.py b/packages/tangle-cli/src/tangle_cli/artifacts.py index b58b7a0..f2f9bca 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -6,14 +6,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import asdict, is_dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import Any, Protocol from .models import ArtifactComponentQuery, ArtifactInfo -if TYPE_CHECKING: - from .client import TangleApiClient - class ArtifactClient(Protocol): """Subset of the static API client used for read-only artifact lookup.""" @@ -25,6 +23,172 @@ def get_execution_details(self, execution_id: str) -> Any: ... def artifacts_get(self, artifact_id: str) -> Any: ... +class ArtifactManager: + """Read-only artifact metadata manager. + + Downstream packages can inject an already-authenticated client or a lazy + ``client_factory`` (for example, one that applies Shopify 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: Callable[[], ArtifactClient] | None = None, + ) -> None: + self._client = client + self._client_factory = client_factory + + @property + def client(self) -> ArtifactClient: + if self._client is None: + if self._client_factory is None: + raise ValueError("ArtifactManager requires a client or client_factory") + self._client = self._client_factory() + return self._client + + 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_filter: list[str] = [] + matched = False + + for query_name in (task_name, key_prefix): + if query_name in tasks_query: + output_filter = tasks_query[query_name] + matched = True + 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_filter = component.outputs if component.outputs else output_filter + matched = True + + out_artifacts = _artifact_id_map(_mapping_or_attr(child_task, "execution_output_artifacts", {})) + if matched and out_artifacts: + for output_name, artifact_id in out_artifacts.items(): + if not output_filter or output_name in output_filter: + 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] = {} + for execution_id, output_filter in execution_ids.items(): + execution = self.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.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.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 + + +# --------------------------------------------------------------------------- +# Compatibility helpers and thin module-level wrappers +# --------------------------------------------------------------------------- + + def _mapping_or_attr(value: Any, key: str, default: Any = None) -> Any: if isinstance(value, dict): return value.get(key, default) @@ -48,87 +212,6 @@ def _artifact_id_map(raw_artifacts: Any) -> dict[str, str]: return artifact_ids -def _collect_artifacts( - 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. - - Handles both direct task matches and component matches at any nesting level. - Returns a dict mapping ``"path/to/task/output_name"`` to artifact ID. - """ - - 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_filter: list[str] = [] - matched = False - - for query_name in (task_name, key_prefix): - if query_name in tasks_query: - output_filter = tasks_query[query_name] - matched = True - 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_filter = component.outputs if component.outputs else output_filter - matched = True - - out_artifacts = _artifact_id_map(_mapping_or_attr(child_task, "execution_output_artifacts", {})) - if matched and out_artifacts: - for output_name, artifact_id in out_artifacts.items(): - if not output_filter or output_name in output_filter: - 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( - _collect_artifacts( - child_execution, - tasks_query, - components_query, - prefix=f"{key_prefix}/", - ) - ) - - return artifact_ids - - -def _collect_execution_artifacts( - client: ArtifactClient, - execution_ids: dict[str, list[str]], -) -> dict[str, str]: - """Collect artifact IDs directly from execution IDs. - - Fetches each execution's details and extracts output artifacts, avoiding the - full run-details tree walk. - """ - - artifact_ids: dict[str, str] = {} - 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 _component_queries(raw_components: list[dict[str, Any]]) -> list[ArtifactComponentQuery]: return [ ArtifactComponentQuery( @@ -146,64 +229,54 @@ def _artifact_info_from_response(response: Any, *, artifact_id: str, key: str) - return ArtifactInfo.from_response(response, key=key) +def _collect_artifacts( + execution: Any, + tasks_query: dict[str, list[str]], + components_query: list[ArtifactComponentQuery], + prefix: str = "", +) -> dict[str, str]: + """Backward-compatible wrapper for :meth:`ArtifactManager.collect_artifacts`.""" + + return ArtifactManager().collect_artifacts(execution, tasks_query, components_query, prefix) + + +def _collect_execution_artifacts( + client: ArtifactClient, + execution_ids: dict[str, list[str]], +) -> dict[str, str]: + """Backward-compatible wrapper for :meth:`ArtifactManager.collect_execution_artifacts`.""" + + return ArtifactManager(client=client).collect_execution_artifacts(execution_ids) + + def get_artifacts( run_id: str, query: dict[str, Any], client: ArtifactClient, ) -> dict[str, ArtifactInfo]: - """Get artifact metadata for tasks/components in a pipeline run. + """Backward-compatible wrapper for :meth:`ArtifactManager.get_artifacts`.""" - Query keys: - - ``tasks``: ``{: []}`` - - ``components``: ``[{"name"|"digest": ..., "outputs": [...]}]`` - - ``executions``: ``{: []}`` - - ``artifact_ids``: ``[, ...]`` + return ArtifactManager(client=client).get_artifacts(run_id, query) - 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] = {} +def serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, Any]]: + """Serialize artifact dict to a JSON-friendly list, dropping ``None`` fields.""" - 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(_collect_execution_artifacts(client, executions_query)) - - tasks_query = query.get("tasks", {}) or {} - components_query_raw = query.get("components", []) or [] - if tasks_query or components_query_raw: - details = 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( - _collect_artifacts( - execution, - tasks_query, - _component_queries(components_query_raw), - ) - ) + return ArtifactManager.serialize_artifacts(artifacts) - artifacts: dict[str, ArtifactInfo] = {} - for key, artifact_id in artifact_ids.items(): - try: - response = 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 +def _serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, Any]]: + """Deprecated compatibility alias; use :func:`serialize_artifacts`.""" + return serialize_artifacts(artifacts) -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 +__all__ = [ + "ArtifactClient", + "ArtifactManager", + "_collect_artifacts", + "_collect_execution_artifacts", + "_serialize_artifacts", + "get_artifacts", + "serialize_artifacts", +] diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py index 85c43fa..96ba6f4 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py @@ -7,11 +7,180 @@ from __future__ import annotations +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as FutureTimeoutError from typing import Any +class PipelineRunDetails: + """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: Callable[[], Any] | None = None, + ) -> None: + self._client = client + self._client_factory = client_factory + + @property + def client(self) -> Any: + if self._client is None: + if self._client_factory is None: + raise ValueError("PipelineRunDetails requires a client or client_factory") + self._client = self._client_factory() + return self._client + + @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 transparency_check + + transparent, reason = 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.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.client.pipeline_runs_get(run_id) + except Exception as exc: + if _status_code(exc) != 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.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} + + +# --------------------------------------------------------------------------- +# Compatibility helpers and thin module-level wrappers +# --------------------------------------------------------------------------- + + def _value(value: Any, key: str, default: Any = None) -> Any: if isinstance(value, dict): return value.get(key, default) @@ -31,70 +200,15 @@ def _to_plain(value: Any) -> Any: def serialize_execution(execution: Any) -> dict[str, Any]: - """Serialize an execution details object into a concise dict.""" + """Backward-compatible wrapper for :meth:`PipelineRunDetails.serialize_execution`.""" - 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 transparency_check - - transparent, reason = 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"] = _to_plain(implementation) - arguments = _value(task_spec, "arguments") - if arguments: - out["arguments"] = _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"] = _to_plain(input_artifacts) - output_artifacts = _value(execution, "output_artifacts") - if output_artifacts: - out["output_artifacts"] = _to_plain(output_artifacts) - return out + return PipelineRunDetails().serialize_execution(execution) def serialize_run_details(details: Any) -> dict[str, Any]: - """Convert ``RunDetails`` into a JSON-serializable dict.""" - - if isinstance(details, dict): - return _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"] = _to_plain(annotations) - if details.execution: - out["execution"] = serialize_execution(details.execution) - if details.annotations: - out["annotations"] = _to_plain(details.annotations) - if details.execution_state: - out["execution_state"] = { - "totals": _to_plain(_value(details.execution_state, "status_totals")), - "per_execution": _to_plain(_value(details.execution_state, "child_execution_status_stats")), - } - return out + """Backward-compatible wrapper for :meth:`PipelineRunDetails.serialize_run_details`.""" + + return PipelineRunDetails().serialize_run_details(details) def get_run_details_output( @@ -106,18 +220,15 @@ def get_run_details_output( include_execution_state: bool = False, execution_id: str | None = None, ) -> dict[str, Any]: - """Fetch run details and return serialized output.""" + """Backward-compatible wrapper for :meth:`PipelineRunDetails.get_run_details_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 = client.get_run_details(run_id, **kwargs) - return serialize_run_details(details) + return PipelineRunDetails(client=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 _status_code(exc: Exception) -> int | None: @@ -126,25 +237,9 @@ def _status_code(exc: Exception) -> int | None: def fetch_graph_state_one(client: Any, run_id: str) -> dict[str, Any]: - """Fetch graph state for a pipeline run id or root execution id.""" - - try: - run = client.pipeline_runs_get(run_id) - except Exception as exc: - if _status_code(exc) != 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 = client.executions_graph_execution_state(root_execution_id) - return { - "run_id": run_id, - "root_execution_id": root_execution_id, - "status_totals": _to_plain(_value(state, "status_totals")), - "failed_execution_ids": _to_plain(_value(state, "failed_execution_ids")), - "per_execution": _to_plain(_value(state, "per_execution")), - "error": None, - } + """Backward-compatible wrapper for :meth:`PipelineRunDetails.fetch_graph_state_one`.""" + + return PipelineRunDetails(client=client).fetch_graph_state_one(run_id) def _error_result(run_id: str, message: str) -> dict[str, Any]: @@ -164,19 +259,16 @@ def get_graph_state_output( *, timeout: float = 30.0, ) -> dict[str, Any]: - """Fetch lightweight graph state for one or more run/execution IDs.""" + """Backward-compatible wrapper for :meth:`PipelineRunDetails.get_graph_state_output`.""" - results: list[dict[str, Any]] = [] - for run_id in run_ids: - executor = ThreadPoolExecutor(max_workers=1) - try: - future = executor.submit(fetch_graph_state_one, client, 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} + return PipelineRunDetails(client=client).get_graph_state_output(run_ids, timeout=timeout) + + +__all__ = [ + "PipelineRunDetails", + "fetch_graph_state_one", + "get_graph_state_output", + "get_run_details_output", + "serialize_execution", + "serialize_run_details", +] diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py index 889c352..1c1ff56 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -13,6 +13,7 @@ import urllib.parse from dataclasses import dataclass from datetime import datetime, timezone +from collections.abc import Callable from typing import Any from .logger import Logger, get_default_logger @@ -39,6 +40,231 @@ class PageChunk: next_ui_filter_url: str | None +class PipelineRunSearch: + """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 while the legacy module-level functions remain + available. + """ + + def __init__( + self, + client: Any = None, + *, + client_factory: Callable[[], Any] | None = None, + logger: Logger | None = None, + ) -> None: + self._client = client + self._client_factory = client_factory + self.logger = logger or get_default_logger() + + @property + def client(self) -> Any: + if self._client is None: + if self._client_factory is None: + raise ValueError("PipelineRunSearch requires a client or client_factory") + self._client = self._client_factory() + return self._client + + @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.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.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.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"), @@ -509,57 +735,16 @@ def search_pipeline_runs( page_token: str | None = None, logger: Logger | None = None, ) -> dict[str, Any]: - """Search pipeline runs and return rows, page metadata, and tables.""" - - log = logger or get_default_logger() - limit = max(1, min(limit, 100)) - resolved_created_by, err = resolve_created_by(created_by=created_by, client=client, logger=log) - if err is not None: - return err - resolved_start, resolved_end = resolve_dates( + """Backward-compatible wrapper for :meth:`PipelineRunSearch.search`.""" + + return PipelineRunSearch(client=client, logger=logger).search( + name=name, + created_by=created_by, + annotations=annotations, start_date=start_date, end_date=end_date, local_time=local_time, - logger=log, - ) - filter_query_dict = query or 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 - log.info(f"Searching pipeline runs (limit={limit})...") - base_url = getattr(client, "base_url", "").rstrip("/") - all_rows, page_chunks, final_next_token = fetch_pipeline_run_search_pages( - client=client, - filter_query_str=filter_query_str, + query=query, 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: - log.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 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 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, ) diff --git a/packages/tangle-cli/src/tangle_cli/secrets.py b/packages/tangle-cli/src/tangle_cli/secrets.py index 8eab8a1..2074948 100644 --- a/packages/tangle-cli/src/tangle_cli/secrets.py +++ b/packages/tangle-cli/src/tangle_cli/secrets.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +from collections.abc import Callable from typing import Any, Protocol @@ -38,6 +39,121 @@ class SecretValueError(ValueError): """Raised when secret value CLI/config inputs are invalid.""" +class SecretsManager: + """Secret resource manager with injectable client construction. + + Downstream packages can inject a Shopify-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: Callable[[], SecretClient] | None = None, + ) -> None: + self._client = client + self._client_factory = client_factory + + @property + def client(self) -> SecretClient: + if self._client is None: + if self._client_factory is None: + raise ValueError("SecretsManager requires a client or client_factory") + self._client = self._client_factory() + return self._client + + @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.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.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.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.client.secrets_delete(secret_name) + return {"status": "success", "action": "deleted", "secret_name": secret_name} + + +# --------------------------------------------------------------------------- +# Compatibility helpers and thin module-level wrappers +# --------------------------------------------------------------------------- + + def _value_from_mapping_or_object(value: Any, key: str, default: Any = None) -> Any: if isinstance(value, dict): return value.get(key, default) @@ -45,42 +161,21 @@ def _value_from_mapping_or_object(value: Any, key: str, default: Any = None) -> 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. - """ + """Backward-compatible wrapper for :meth:`SecretsManager.resolve_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") + return SecretsManager.resolve_secret_value(value, from_env) def _secret_metadata(secret: Any) -> dict[str, Any]: - """Return JSON-safe secret metadata, excluding any secret value fields.""" + """Backward-compatible wrapper for :meth:`SecretsManager.secret_metadata`.""" - 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 + return SecretsManager.secret_metadata(secret) def list_secrets(client: SecretClient) -> dict[str, Any]: - """List secret metadata without exposing secret values.""" + """Backward-compatible wrapper for :meth:`SecretsManager.list`.""" - response = client.secrets_list() - raw_secrets = _value_from_mapping_or_object(response, "secrets", []) or [] - secrets = [_secret_metadata(secret) for secret in raw_secrets] - return {"status": "success", "count": len(secrets), "secrets": secrets} + return SecretsManager(client=client).list() def create_secret( @@ -92,16 +187,15 @@ def create_secret( description: str | None = None, expires_at: str | None = None, ) -> dict[str, Any]: - """Create a secret using generated static API operations.""" + """Backward-compatible wrapper for :meth:`SecretsManager.create`.""" - secret_value = _resolve_secret_value(value, from_env) - secret = client.secrets_create( + return SecretsManager(client=client).create( secret_name, - secret_value, + value=value, + from_env=from_env, description=description, expires_at=expires_at, ) - return {"status": "success", "action": "created", "secret": _secret_metadata(secret)} def update_secret( @@ -113,20 +207,30 @@ def update_secret( description: str | None = None, expires_at: str | None = None, ) -> dict[str, Any]: - """Update a secret using generated static API operations.""" + """Backward-compatible wrapper for :meth:`SecretsManager.update`.""" - secret_value = _resolve_secret_value(value, from_env) - secret = client.secrets_update( + return SecretsManager(client=client).update( secret_name, - secret_value, + value=value, + from_env=from_env, description=description, expires_at=expires_at, ) - return {"status": "success", "action": "updated", "secret": _secret_metadata(secret)} def delete_secret(client: SecretClient, secret_name: str) -> dict[str, Any]: - """Delete a secret using generated static API operations.""" + """Backward-compatible wrapper for :meth:`SecretsManager.delete`.""" + + return SecretsManager(client=client).delete(secret_name) + - client.secrets_delete(secret_name) - return {"status": "success", "action": "deleted", "secret_name": secret_name} +__all__ = [ + "SecretClient", + "SecretValueError", + "SecretsManager", + "_resolve_secret_value", + "create_secret", + "delete_secret", + "list_secrets", + "update_secret", +] diff --git a/tests/test_resource_managers.py b/tests/test_resource_managers.py new file mode 100644 index 0000000..9c3d6df --- /dev/null +++ b/tests/test_resource_managers.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from tangle_cli.artifacts import ArtifactManager, get_artifacts, serialize_artifacts +from tangle_cli.models import ArtifactInfo +from tangle_cli.pipeline_run_details import PipelineRunDetails, get_graph_state_output, get_run_details_output +from tangle_cli.pipeline_run_search import PipelineRunSearch, search_pipeline_runs +from tangle_cli.secrets import SecretValueError, SecretsManager, create_secret, list_secrets + + +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 serialize_artifacts(artifacts) == [ + { + "id": "artifact-1", + "uri": "gs://bucket/artifact-1", + "key": "artifact-1", + "total_size": 0, + "is_dir": False, + } + ] + + +def test_artifact_function_wrapper_delegates_to_manager() -> None: + client = ArtifactClient() + + artifacts = get_artifacts("run-1", {"executions": {"exec-1": ["model"]}}, client=client) + + assert artifacts["exec-1/model"].id == "artifact-model" + assert client.calls == ["execution:exec-1", "artifact:artifact-model"] + + +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")] + assert list_secrets(client)["count"] == 1 + assert create_secret(client, "WRAPPED", value="wrapped")["action"] == "created" + + 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_and_function_wrapper() -> 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"}}] + } + + wrapped = search_pipeline_runs(client=client, query={"and": []}, limit=1) + assert wrapped["count"] == 1 + + +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_and_function_wrappers() -> 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", + } + assert get_run_details_output(client, "run-2")["run"]["id"] == "run-2" + + graph = manager.get_graph_state_output(["run-1"]) + assert graph["results"][0]["status_totals"] == {"SUCCEEDED": 1} + assert get_graph_state_output(client, ["exec-root"])["results"][0]["root_execution_id"] == "exec-root" + + +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_import_surface() -> None: + from tangle_cli.artifacts import ArtifactManager as ImportedArtifactManager + from tangle_cli.artifacts import serialize_artifacts as imported_serialize_artifacts + from tangle_cli.pipeline_run_details import PipelineRunDetails as ImportedPipelineRunDetails + from tangle_cli.pipeline_run_search import PipelineRunSearch as ImportedPipelineRunSearch + from tangle_cli.secrets import SecretsManager as ImportedSecretsManager + + assert ImportedArtifactManager is ArtifactManager + assert imported_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 diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 52c3325..162bbd6 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -26,13 +26,16 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: publish_component_to_tangle, ) from tangle_cli.artifacts import ( + ArtifactManager, _collect_artifacts, _collect_execution_artifacts, _serialize_artifacts, get_artifacts, + serialize_artifacts, ) from tangle_cli.module_bundler import ModuleBundler from tangle_cli.secrets import ( + SecretsManager, _resolve_secret_value, create_secret, delete_secret, @@ -40,6 +43,8 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: update_secret, ) 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 ( get_standard_library, inspect_by_digest, @@ -143,10 +148,15 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert callable(deprecate_component) assert callable(bump_version) assert ModuleBundler is not None + assert ArtifactManager is not None assert callable(_collect_artifacts) assert callable(_collect_execution_artifacts) assert callable(_serialize_artifacts) + assert callable(serialize_artifacts) assert callable(get_artifacts) + assert SecretsManager is not None + assert PipelineRunDetails is not None + assert PipelineRunSearch is not None assert callable(_resolve_secret_value) assert callable(list_secrets) assert callable(create_secret) From 192d3639e12b3908c36e1ebef473f72494d2eb36 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 05:49:40 -0700 Subject: [PATCH 069/111] fix: keep artifact manager native-free --- .../tangle-cli/src/tangle_cli/artifacts.py | 71 +++++++++++++++++- packages/tangle-cli/src/tangle_cli/models.py | 72 +------------------ tests/test_resource_managers.py | 37 ++++++++++ 3 files changed, 107 insertions(+), 73 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/artifacts.py b/packages/tangle-cli/src/tangle_cli/artifacts.py index f2f9bca..4b930ac 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -7,11 +7,9 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import asdict, is_dataclass +from dataclasses import asdict, dataclass, field, is_dataclass from typing import Any, Protocol -from .models import ArtifactComponentQuery, ArtifactInfo - class ArtifactClient(Protocol): """Subset of the static API client used for read-only artifact lookup.""" @@ -23,6 +21,65 @@ 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: """Read-only artifact metadata manager. @@ -195,6 +252,12 @@ def _mapping_or_attr(value: Any, key: str, default: Any = None) -> Any: 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}``.""" @@ -273,6 +336,8 @@ def _serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, A __all__ = [ "ArtifactClient", + "ArtifactComponentQuery", + "ArtifactInfo", "ArtifactManager", "_collect_artifacts", "_collect_execution_artifacts", diff --git a/packages/tangle-cli/src/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py index f5428b0..abe29bc 100644 --- a/packages/tangle-cli/src/tangle_cli/models.py +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -9,19 +9,11 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import TYPE_CHECKING, Any +from typing import Any from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse -from .utils import ( - _optional_str, - _strip_text_from_graph, - _value_from_mapping_or_object, - add_official_prefix, -) - -if TYPE_CHECKING: - from tangle_api.generated.models import GetArtifactInfoResponse +from .artifacts import ArtifactComponentQuery, ArtifactInfo # ---- Execution / Run dataclasses ------------------------------------------- @@ -271,66 +263,6 @@ class RunDetails: execution_state: GraphExecutionState | None = None -# ---- Artifacts ------------------------------------------------------------- - - -@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 with gs:// URI from GET /api/artifacts/{id}.""" - 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=_value_from_mapping_or_object(ad, "uri", ""), - key=key, - total_size=_value_from_mapping_or_object(ad, "total_size", 0), - is_dir=_value_from_mapping_or_object(ad, "is_dir", False), - hash=_optional_str(_value_from_mapping_or_object(ad, "hash")), - created_at=_optional_str(_value_from_mapping_or_object(ad, "created_at")), - ) - - @classmethod - def from_response( - cls, - response: GetArtifactInfoResponse, - *, - key: str = "", - ) -> ArtifactInfo: - """Create a flattened artifact DTO from a generated artifact response.""" - - artifact_data = getattr(response, "artifact_data", None) - total_size = _value_from_mapping_or_object(artifact_data, "total_size", 0) - is_dir = _value_from_mapping_or_object(artifact_data, "is_dir", False) - return cls( - id=str(getattr(response, "id", "") or ""), - uri=str(_value_from_mapping_or_object(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(_value_from_mapping_or_object(artifact_data, "hash")), - created_at=_optional_str(_value_from_mapping_or_object(artifact_data, "created_at")), - ) - - # ---- Users / secrets ------------------------------------------------------- diff --git a/tests/test_resource_managers.py b/tests/test_resource_managers.py index 9c3d6df..edd80b8 100644 --- a/tests/test_resource_managers.py +++ b/tests/test_resource_managers.py @@ -1,5 +1,8 @@ from __future__ import annotations +import subprocess +import sys +import textwrap from types import SimpleNamespace from typing import Any @@ -244,6 +247,40 @@ def factory() -> DetailsClient: 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 + +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 +''' + 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.artifacts import serialize_artifacts as imported_serialize_artifacts From fa9e3500fdf7967913e6ee7d3b228edf44ab850d Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 08:57:04 -0700 Subject: [PATCH 070/111] feat(tangle-cli): add pipeline runner orchestrator --- .../src/tangle_cli/pipeline_runner.py | 461 ++++++++++++++++++ tests/test_pipeline_runs_cli.py | 76 +++ tests/test_resource_managers.py | 5 + 3 files changed, 542 insertions(+) create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_runner.py 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..2b8e474 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -0,0 +1,461 @@ +"""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_runs`. +Downstream-specific behavior (Shopify auth, gs:// I/O, Slack/Observe, mutexes, +schedulers, service-account annotations, and legacy result shapes) is exposed as +hooks rather than imported here. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping + +from .pipeline_runs import PipelineRunContext, PipelineRunError, PipelineRunHooks, PipelineRunManager + + +@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, + 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. + """ + + return ( + self.hydrate_pipeline( + pipeline_path, + client=client, + resolution_overrides=resolution_overrides, + ), + 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 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") + success = None if wait_result.get("timed_out") else not bool(wait_result.get("early_exit")) + return { + **result, + "success": success, + "status": status, + "pipeline_name": preparation.pipeline_name, + "run_id": run_id, + "root_execution_id": root_execution_id, + "preparation": preparation, + } + + +@dataclass +class PipelineRunner(PipelineRunManager): + """Generic high-level pipeline runner orchestration.""" + + hooks: PipelineRunnerHooks = field(default_factory=PipelineRunnerHooks) + + @staticmethod + def _ensure_mapping(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise PipelineRunError("pipeline spec must be a mapping") + return value + + 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.""" + + pipeline_name = self.hooks.initial_pipeline_name(pipeline_path) + effective_path: str | Path | None = pipeline_path + if hydrate: + pipeline_spec, hydrated_effective_path = self.hooks.hydrate_pipeline_for_run( + pipeline_path, + client=self.client, + resolution_overrides=resolution_overrides, + ) + if hydrated_effective_path is not None: + effective_path = hydrated_effective_path + else: + pipeline_spec = self.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 = self.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 self.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 = self.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 = self.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 = self.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 + + return PipelinePreparationResult( + pipeline_spec=pipeline_spec, + pipeline_name=pipeline_name, + effective_path=effective_path, + ) + + 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.""" + + body = self.build_submit_body_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_body(body, 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": 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", + 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.""" + + 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, + ) + attempts = max_attempts if max_attempts is not None else (retry + 1 if wait else 1) + run_metadata = self.hooks.metadata_for_run( + pipeline_name=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, + ) + result = self.run_pipeline_spec( + preparation.pipeline_spec, + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=False, + 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, + metadata=run_metadata, + ) + return self.hooks.format_run_result(result, preparation=preparation) diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index fec1a97..e184ecd 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -11,6 +11,7 @@ import yaml from tangle_cli import cli, pipeline_runs_cli +from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks from tangle_cli.pipeline_runs import PipelineRunContext, PipelineRunHooks, PipelineRunManager, PipelineRunError @@ -1421,3 +1422,78 @@ def test_pipeline_runs_run_as_is_extension_seam(tmp_path: Path): 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"] == "Demo Pipeline" + 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]["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 diff --git a/tests/test_resource_managers.py b/tests/test_resource_managers.py index edd80b8..da33b49 100644 --- a/tests/test_resource_managers.py +++ b/tests/test_resource_managers.py @@ -264,6 +264,7 @@ def guarded_import(name, *args, **kwargs): 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" @@ -271,6 +272,8 @@ def guarded_import(name, *args, **kwargs): 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)], @@ -286,6 +289,7 @@ def test_resource_manager_import_surface() -> None: from tangle_cli.artifacts import serialize_artifacts as imported_serialize_artifacts 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 @@ -293,3 +297,4 @@ def test_resource_manager_import_surface() -> None: assert ImportedSecretsManager is SecretsManager assert ImportedPipelineRunSearch is PipelineRunSearch assert ImportedPipelineRunDetails is PipelineRunDetails + assert ImportedPipelineRunner is not None From d62352639c402adfb3d9ca54c69ffab379eacc42 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 09:03:39 -0700 Subject: [PATCH 071/111] fix(tangle-cli): rerun pipeline preparation on retry --- .../src/tangle_cli/pipeline_runner.py | 282 +++++++++++------- .../src/tangle_cli/pipeline_runs.py | 5 + tests/test_pipeline_runs_cli.py | 90 ++++++ 3 files changed, 276 insertions(+), 101 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 2b8e474..9462fe4 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -17,6 +17,9 @@ from .pipeline_runs import PipelineRunContext, PipelineRunError, PipelineRunHooks, PipelineRunManager +_SUCCESS_STATUSES = {"SUCCEEDED"} +_FAILURE_STATUSES = {"FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "INVALID"} + @dataclass(frozen=True) class PipelinePreparationResult: @@ -220,6 +223,21 @@ def metadata_for_run( 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], @@ -244,7 +262,15 @@ def format_run_result( success: bool | None = True if wait_result is not None: status = str(wait_result.get("status") or "unknown") - success = None if wait_result.get("timed_out") else not bool(wait_result.get("early_exit")) + status_upper = status.upper() + if wait_result.get("timed_out"): + success = None + elif status_upper in _SUCCESS_STATUSES: + success = True + elif status_upper in _FAILURE_STATUSES: + success = False + else: + success = None return { **result, "success": success, @@ -284,79 +310,95 @@ def prepare_pipeline_for_run( pipeline_name = self.hooks.initial_pipeline_name(pipeline_path) effective_path: str | Path | None = pipeline_path - if hydrate: - pipeline_spec, hydrated_effective_path = self.hooks.hydrate_pipeline_for_run( - pipeline_path, - client=self.client, - resolution_overrides=resolution_overrides, + pipeline_spec: Any = {} + preparation: PipelinePreparationResult | None = None + try: + if hydrate: + pipeline_spec, hydrated_effective_path = self.hooks.hydrate_pipeline_for_run( + pipeline_path, + client=self.client, + resolution_overrides=resolution_overrides, + ) + if hydrated_effective_path is not None: + effective_path = hydrated_effective_path + else: + pipeline_spec = self.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 = self.hooks.prepare_loaded_pipeline_spec( + pipeline_spec, + pipeline_path=pipeline_path, + effective_path=effective_path, + hydrate=hydrate, + run_args=run_args, ) - if hydrated_effective_path is not None: - effective_path = hydrated_effective_path - else: - pipeline_spec = self.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 = self.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 + 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 self.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 = self.hooks.apply_layout( + if self.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 = self.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 = self.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 = self.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 - validation_errors = self.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 = self.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 - - return PipelinePreparationResult( - pipeline_spec=pipeline_spec, - pipeline_name=pipeline_name, - effective_path=effective_path, - ) + 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 {} + self.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, @@ -417,45 +459,83 @@ def run_pipeline( include_next_steps: bool = False, metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Run a pipeline path through generic preparation + lifecycle hooks.""" + """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. + """ - 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, - ) attempts = max_attempts if max_attempts is not None else (retry + 1 if wait else 1) - run_metadata = self.hooks.metadata_for_run( - pipeline_name=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, - ) - result = self.run_pipeline_spec( - preparation.pipeline_spec, - run_args=run_args, - annotations=annotations, - pipeline_path=pipeline_path, - run_as=run_as, - hydrate=False, - 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, - metadata=run_metadata, - ) - return self.hooks.format_run_result(result, preparation=preparation) + preparations: dict[int, PipelinePreparationResult] = {} + + 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) + return self.build_submit_body_from_spec( + copy.deepcopy(preparation.pipeline_spec), + run_args=run_args, + annotations=annotations, + pipeline_path=pipeline_path, + run_as=run_as, + hydrate=False, + ) + + def metadata_factory( + attempt: int, + _previous_context: PipelineRunContext | None, + _error: Exception | None, + ) -> dict[str, Any]: + preparation = preparations[attempt] + return self.hooks.metadata_for_run( + pipeline_name=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, + metadata_factory=metadata_factory, + ) + context = result.get("context") + attempt = context.attempt if isinstance(context, PipelineRunContext) else max(preparations) + return self.hooks.format_run_result(result, preparation=preparations[attempt]) + except Exception as exc: + error = exc + raise + finally: + for preparation in preparations.values(): + self.hooks.cleanup_prepared_pipeline(preparation, error=error) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 8093e1b..232c4a2 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -996,6 +996,9 @@ def _run_body_factory( allow_zero_poll_interval: bool = False, timeout_clock: str = "monotonic", 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.""" @@ -1016,6 +1019,8 @@ def _run_body_factory( error: Exception | None = None retry_requested = False body = body_factory(attempt, previous_context, last_error) + 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 diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index e184ecd..20c8ef3 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1497,3 +1497,93 @@ def apply_layout(self, pipeline_spec, **kwargs): # type: ignore[no-untyped-def] ) 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"} + + 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-2", + ] + + +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() From d65ed85b043a6fa75b167185883ff224ef6b106f Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 12:22:50 -0700 Subject: [PATCH 072/111] fix(tangle-cli): make trusted globs segment-aware Avoid matching trusted hydration glob patterns against full absolute paths with fnmatch, which lets single-segment wildcards span nested path components. Resolve the glob prefix, match only the relative suffix, and require the same number of path segments. Validation: uv run pytest tests/test_pipelines_cli.py::test_trusted_python_source_globs_are_path_segment_aware tests/test_pipelines_cli.py tests/test_pipeline_runs_cli.py -q; uv run pytest tests -q; uv run ruff check packages/tangle-cli/src/tangle_cli/hydration_trust.py tests/test_pipelines_cli.py; uv build; uv build --sdist --wheel --package tangle-api; git diff --check --- .../src/tangle_cli/hydration_trust.py | 24 ++++++++++++++++--- tests/test_pipelines_cli.py | 21 ++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/hydration_trust.py b/packages/tangle-cli/src/tangle_cli/hydration_trust.py index 9f86110..f4ace41 100644 --- a/packages/tangle-cli/src/tangle_cli/hydration_trust.py +++ b/packages/tangle-cli/src/tangle_cli/hydration_trust.py @@ -2,8 +2,8 @@ from __future__ import annotations -import fnmatch import os +from fnmatch import fnmatchcase from pathlib import Path from typing import Iterable @@ -187,8 +187,26 @@ def _matches_glob_source(candidate: Path, source: str) -> bool: prefix = Path(*prefix_parts).resolve() else: prefix = Path.cwd().resolve() - pattern = (prefix / Path(*suffix_parts)).as_posix() - return fnmatch.fnmatch(candidate.as_posix(), pattern) + 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: diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index cc8ec26..84f7f34 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -678,6 +678,27 @@ def fake_regenerate_yaml(**kwargs): 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, From 1c79fd12f870e183b73ad943558eafad684323bc Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 13:44:04 -0700 Subject: [PATCH 073/111] fix(tangle-cli): honor stripped bundle source and invalid terminal Analyze the stripped runtime program when collecting bundle-mode local imports so authoring-only imports removed from the baked source are not bundled and eagerly executed at runtime. Also treat INVALID graph-state counts as terminal in the pipeline-run wait path. Validation: uv run pytest tests/test_component_from_func.py tests/test_pipeline_runs_cli.py -q; uv run pytest tests -q; uv run ruff check packages/tangle-cli/src/tangle_cli/component_from_func.py packages/tangle-cli/src/tangle_cli/pipeline_runs.py tests/test_component_from_func.py tests/test_pipeline_runs_cli.py; uv build; uv build --sdist --wheel --package tangle-api; git diff --check --- .../src/tangle_cli/component_from_func.py | 1 + .../src/tangle_cli/pipeline_runs.py | 2 +- tests/test_component_from_func.py | 55 ++++++++++++++++++- tests/test_pipeline_runs_cli.py | 32 +++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index 68a2fcf..bb27793 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -1817,6 +1817,7 @@ def _path_annotation(path: Path) -> str: file_path, resolve_root=resolve_root, pip_deps=deps, + source=spec.module_source_stripped, ) if module_sources: bundled_modules_b64 = ModuleBundler.encode(module_sources) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 232c4a2..d096bb0 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -27,7 +27,7 @@ from .pipeline_run_search import search_pipeline_runs from .utils import dump_yaml -_TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED") +_TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED", "INVALID") _ACTIVE_STATUSES = ("RUNNING", "CANCELLING", "CANCELING", "PENDING", "QUEUED") diff --git a/tests/test_component_from_func.py b/tests/test_component_from_func.py index 948ea1a..46ddcb7 100644 --- a/tests/test_component_from_func.py +++ b/tests/test_component_from_func.py @@ -6,7 +6,6 @@ import sys import textwrap from pathlib import Path -from unittest.mock import patch import pytest import yaml @@ -549,6 +548,60 @@ def func(x: str): 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. diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 20c8ef3..a037de7 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -868,6 +868,38 @@ def executions_graph_execution_state(self, id: str) -> dict[str, Any]: } +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_graph_state_counts_supports_mapping_like_objects() -> None: assert PipelineRunManager.status_counts_from_graph_state( SimpleNamespace(status_totals={"SUCCEEDED": 1}) From 8bc5ff410850fa186c5fb05a612416a89d124f74 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 15:50:38 -0700 Subject: [PATCH 074/111] fix(tangle-cli): preserve authoring strip failures Let AuthoringStripError escape the component-generator wrapper so local_from_python generation failures do not silently fall back to published components. Also reject non-string pipeline task ids during validation instead of stringifying them and crashing later in layout/dependency paths. Validation: uv run pytest tests/test_component_generator.py tests/test_pipelines_cli.py -q; uv run pytest tests -q; uv run ruff check packages/tangle-cli/src/tangle_cli/component_generator.py packages/tangle-cli/src/tangle_cli/pipelines.py tests/test_component_generator.py tests/test_pipelines_cli.py; uv build; uv build --sdist --wheel --package tangle-api; git diff --check --- .../src/tangle_cli/component_generator.py | 4 ++++ .../tangle-cli/src/tangle_cli/pipelines.py | 17 ++++++++++---- tests/test_component_generator.py | 23 ++++++++++++++++++- tests/test_pipelines_cli.py | 23 +++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/component_generator.py b/packages/tangle-cli/src/tangle_cli/component_generator.py index 6a620e3..0342190 100644 --- a/packages/tangle-cli/src/tangle_cli/component_generator.py +++ b/packages/tangle-cli/src/tangle_cli/component_generator.py @@ -162,6 +162,10 @@ def _run_generation( log(f" ✅ Generated: {final_output}") return True except Exception as exc: + if exc.__class__.__name__ == "AuthoringStripError": + if final_output.exists(): + final_output.unlink() + raise log(f" ❌ Error: {exc}") if final_output.exists(): final_output.unlink() diff --git a/packages/tangle-cli/src/tangle_cli/pipelines.py b/packages/tangle-cli/src/tangle_cli/pipelines.py index fa2a0a0..f1172cc 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -148,11 +148,18 @@ def _validate_graph_spec( errors.append(f"{path}.{TASKS_PATH} must be an object") return - task_names = set(str(name) for name in tasks.keys()) + 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 @@ -489,7 +496,7 @@ def _layout_graph_spec(spec: Mapping[str, Any], *, x_spacing: int, y_spacing: in def _task_layers(tasks: Mapping[str, Any]) -> list[list[str]]: - task_names = [str(name) for name in tasks.keys()] + 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} @@ -526,12 +533,12 @@ def _task_layers(tasks: Mapping[str, Any]) -> list[list[str]]: def _dependency_edges(tasks: Mapping[str, Any]) -> set[tuple[str, str]]: edges: set[tuple[str, str]] = set() - task_names = set(str(name) for name in tasks.keys()) + task_names = {name for name in tasks.keys() if isinstance(name, str)} for task_id, task_spec in tasks.items(): - if not isinstance(task_spec, Mapping): + if not isinstance(task_id, str) or not isinstance(task_spec, Mapping): continue - target = str(task_id) + target = task_id dependencies = task_spec.get("dependencies", []) if isinstance(dependencies, list): for dependency in dependencies: diff --git a/tests/test_component_generator.py b/tests/test_component_generator.py index bd22fed..73ac3ea 100644 --- a/tests/test_component_generator.py +++ b/tests/test_component_generator.py @@ -8,7 +8,7 @@ import yaml from tangle_cli import cli -from tangle_cli.component_from_func import generate_component_yaml +from tangle_cli.component_from_func import AuthoringStripError, generate_component_yaml from tangle_cli.component_generator import ( DEFAULT_CONTAINER_IMAGE, determine_output_path, @@ -238,6 +238,27 @@ def my_component(name: str) -> str: 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" diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 84f7f34..c8dc400 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -183,6 +183,29 @@ def test_pipelines_validate_fails_for_invalid_yaml(tmp_path: Path): 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() From 50bbba3b28c229db81a57651af356fc4e7f0b5a9 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 17:14:19 -0700 Subject: [PATCH 075/111] fix(tangle-cli): scope local hydration cache by source dir Include the active base directory in cache keys for local refs so nested subgraphs with the same relative file refs do not reuse components from another subtree. Also serialize generated graph-state response objects before printing JSON. Validation: uv run pytest tests/test_pipelines_cli.py tests/test_pipeline_runs_cli.py -q; uv run pytest tests -q; uv run ruff check packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py packages/tangle-cli/src/tangle_cli/pipeline_runs.py tests/test_pipelines_cli.py tests/test_pipeline_runs_cli.py; uv build; uv build --sdist --wheel --package tangle-api; git diff --check --- .../src/tangle_cli/pipeline_hydrator.py | 16 ++++- .../src/tangle_cli/pipeline_runs.py | 12 ++-- tests/test_pipeline_runs_cli.py | 17 +++++ tests/test_pipelines_cli.py | 72 +++++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index da31493..1d123bf 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -255,9 +255,19 @@ def _recursive_context_value(value: Any) -> str | None: return "child-priority" raise ValueError(f"Unsupported recursive_context: {value!r}") - def _cache_key(self, ref_type: str, ref_value: str) -> str: + 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 ref_type in {"local", "local_from_python"} or ( + ref_type == "url" and self._uri_scheme(ref_value) in {None, "file"} + ): + 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}" @@ -1189,7 +1199,7 @@ def _resolve_component_ref( 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) + 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: @@ -1225,7 +1235,7 @@ def _try_resolve_single_ref( 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) + 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) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index d096bb0..68f5a25 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -344,6 +344,12 @@ def to_plain(value: Any) -> Any: 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 @@ -776,12 +782,6 @@ def cancel_run(self, run_id: str) -> dict[str, Any]: def graph_state(self, execution_id: str) -> Mapping[str, Any] | Any: graph_state = self.client.executions_graph_execution_state(execution_id) - if not isinstance(graph_state, Mapping) and ( - hasattr(graph_state, "status_totals") - or hasattr(graph_state, "execution_status_stats") - or hasattr(graph_state, "child_execution_status_stats") - ): - return graph_state return self.to_plain(graph_state) def graph_state_output(self, run_ids: list[str], *, timeout: float = 30.0) -> dict[str, Any]: diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index a037de7..4f6d4f8 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -586,6 +586,23 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: } +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()) diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index c8dc400..8d2ec45 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -459,6 +459,78 @@ def test_pipelines_hydrate_nested_file_refs_use_loaded_component_source_dir( 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_resolve_url_fragment_uses_config_relative_local_refs( tmp_path: Path, capsys, From 8bcbf7e9b9d04cbb30c484317d5727b36bf6488b Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 17:18:56 -0700 Subject: [PATCH 076/111] fix(tangle-cli): scope relative resolve refs by source dir Extend local-context cache scoping to resolve:// URLs whose config path is relative or file-relative so identical relative resolve refs in different nested subgraphs do not collide. Validation: uv run pytest tests/test_pipelines_cli.py tests/test_pipeline_runs_cli.py -q; uv run pytest tests -q; uv run ruff check packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py packages/tangle-cli/src/tangle_cli/pipeline_runs.py tests/test_pipelines_cli.py tests/test_pipeline_runs_cli.py; uv build; uv build --sdist --wheel --package tangle-api; git diff --check --- .../src/tangle_cli/pipeline_hydrator.py | 28 +++++++- tests/test_pipelines_cli.py | 66 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 1d123bf..9b90e53 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -263,9 +263,7 @@ def _cache_key( ) -> str: """Compute a cache key for a component reference.""" key = f"{ref_type}:{ref_value}" - if ref_type in {"local", "local_from_python"} or ( - ref_type == "url" and self._uri_scheme(ref_value) in {None, "file"} - ): + 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: @@ -273,6 +271,30 @@ def _cache_key( 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. diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 8d2ec45..8eca648 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -531,6 +531,72 @@ def test_pipelines_hydrate_cache_separates_same_relative_ref_by_source_dir( 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, From 62d9412045719c5e9e7ac2451af8e771205da187 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sun, 14 Jun 2026 21:45:12 -0700 Subject: [PATCH 077/111] fix(tangle-cli): harden bundled helpers and selectors Preserve package modules during runtime bundle injection so nested package relative imports resolve correctly. Keep annotation values optional in pipeline-runs annotations set, and union artifact output filters across multiple matching selectors. Validation: uv run pytest tests/test_module_bundler.py tests/test_pipeline_runs_cli.py tests/test_artifacts.py -q; uv run pytest tests -q; uv run ruff check packages/tangle-cli/src/tangle_cli/module_bundler.py packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py packages/tangle-cli/src/tangle_cli/artifacts.py tests/test_module_bundler.py tests/test_pipeline_runs_cli.py tests/test_artifacts.py; uv build; uv build --sdist --wheel --package tangle-api; git diff --check --- .../tangle-cli/src/tangle_cli/artifacts.py | 19 +++++++----- .../src/tangle_cli/module_bundler.py | 16 ++++++++-- .../src/tangle_cli/pipeline_runs_cli.py | 2 +- tests/test_artifacts.py | 30 +++++++++++++++++++ tests/test_module_bundler.py | 11 +++++++ tests/test_pipeline_runs_cli.py | 3 ++ 6 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/artifacts.py b/packages/tangle-cli/src/tangle_cli/artifacts.py index 4b930ac..9f6baa0 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -124,13 +124,11 @@ def collect_artifacts( 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_filter: list[str] = [] - matched = False + output_filters: list[list[str]] = [] for query_name in (task_name, key_prefix): if query_name in tasks_query: - output_filter = tasks_query[query_name] - matched = True + output_filters.append(tasks_query[query_name]) break child_digest = _mapping_or_attr(child_task, "digest") @@ -139,13 +137,18 @@ def collect_artifacts( if (component.digest and child_digest == component.digest) or ( component.name and child_name == component.name ): - output_filter = component.outputs if component.outputs else output_filter - matched = True + output_filters.append(component.outputs) out_artifacts = _artifact_id_map(_mapping_or_attr(child_task, "execution_output_artifacts", {})) - if matched and out_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 not output_filter or output_name in output_filter: + 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): diff --git a/packages/tangle-cli/src/tangle_cli/module_bundler.py b/packages/tangle-cli/src/tangle_cli/module_bundler.py index 859c7c1..bf796ce 100644 --- a/packages/tangle-cli/src/tangle_cli/module_bundler.py +++ b/packages/tangle-cli/src/tangle_cli/module_bundler.py @@ -293,6 +293,11 @@ def build_injection(bundled_modules_b64: str) -> str: # 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)): @@ -302,9 +307,14 @@ def build_injection(bundled_modules_b64: str) -> str: _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 + _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 diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index 4b0544d..14d5fc5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -517,7 +517,7 @@ def pipeline_runs_annotations_set( specs = { "run_id": (run_id,), "key": (key,), - "value": (value,), + "value": (value, None), "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 0ab3c75..d96d7db 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -153,6 +153,36 @@ def test_get_artifacts_resolves_component_name_and_digest_queries() -> None: 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 = get_artifacts( + "run-1", + { + "tasks": {"Train": ["model"]}, + "components": [{"digest": "sha256:trainer", "outputs": ["metrics"]}], + }, + client=client, + ) + + 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( diff --git a/tests/test_module_bundler.py b/tests/test_module_bundler.py index 613f270..95e7766 100644 --- a/tests/test_module_bundler.py +++ b/tests/test_module_bundler.py @@ -334,6 +334,17 @@ def test_chain_dependency_runs_correctly(self): 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. diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 4f6d4f8..0397333 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -471,6 +471,9 @@ def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: 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")] From 0913fc566ebbf019d12945bcbce94e8fc6bccca5 Mon Sep 17 00:00:00 2001 From: Volv G Date: Mon, 15 Jun 2026 07:36:07 -0700 Subject: [PATCH 078/111] feat(tangle-cli): add native fail-fast wait policy Thread exit_on_first_failure through pipeline run waiting and make the default hook exit early when enabled and graph status counts contain FAILED or SYSTEM_ERROR. Expose wait-result metadata for hooks so downstreams can compose timeout/early-exit notifications without re-deriving counts. Validation: uv run --frozen pytest -q; uv run --frozen ruff check packages/tangle-cli/src/tangle_cli/pipeline_runs.py packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py packages/tangle-cli/src/tangle_cli/pipeline_runner.py tests/test_pipeline_runs_cli.py; uv build --sdist --wheel; uv build --sdist --wheel --package tangle-api; git diff --check --- .../src/tangle_cli/pipeline_runner.py | 2 + .../src/tangle_cli/pipeline_runs.py | 71 +++++++++- .../src/tangle_cli/pipeline_runs_cli.py | 3 + tests/test_pipeline_runs_cli.py | 123 ++++++++++++++++++ 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 9462fe4..7cf2473 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -451,6 +451,7 @@ def run_pipeline( 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, @@ -528,6 +529,7 @@ def metadata_factory( 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") diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py index 68f5a25..1d6c21a 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs.py @@ -29,6 +29,7 @@ _TERMINAL_STATUSES = ("FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "SUCCEEDED", "INVALID") _ACTIVE_STATUSES = ("RUNNING", "CANCELLING", "CANCELING", "PENDING", "QUEUED") +_FAILURE_EARLY_EXIT_STATUSES = ("FAILED", "SYSTEM_ERROR") class PipelineRunError(RuntimeError): @@ -250,9 +251,16 @@ def after_poll(self, poll: PipelineWaitPoll, context: PipelineRunContext) -> Non """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.""" + """Return True to stop waiting before terminal/timeout. - return False + 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.""" @@ -920,8 +928,11 @@ def wait_for_completion( 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): @@ -961,18 +972,21 @@ def wait_for_completion( 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 = {"run": poll.run, "status": poll.status, "timed_out": False} + result = self._wait_result(poll, timed_out=False) 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 = {"run": poll.run, "status": poll.status, "timed_out": False, "early_exit": True} + result = self._wait_result(poll, timed_out=False, early_exit=True) 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 = {"run": poll.run, "status": poll.status, "timed_out": True} + result = self._wait_result(poll, timed_out=True) self.hooks.after_wait_context(result, wait_context) return result if deadline is None: @@ -983,6 +997,45 @@ def wait_for_completion( 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 + + @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 + def _run_body_factory( self, body_factory: Callable[[int, PipelineRunContext | None, Exception | None], dict[str, Any]], @@ -995,6 +1048,7 @@ def _run_body_factory( 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] @@ -1051,6 +1105,7 @@ def _run_body_factory( 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: @@ -1102,6 +1157,7 @@ def run_prepared_body( ] | 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. @@ -1129,6 +1185,7 @@ def body_factory( 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, ) @@ -1148,6 +1205,7 @@ def run_pipeline_spec( 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.""" @@ -1176,6 +1234,7 @@ def body_factory( 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, ) @@ -1195,6 +1254,7 @@ def run_pipeline( 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. @@ -1228,6 +1288,7 @@ def body_factory( 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, ) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index 14d5fc5..efe7a2d 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -288,6 +288,7 @@ def pipeline_runs_wait( *, 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, @@ -300,6 +301,7 @@ def pipeline_runs_wait( "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), } @@ -311,6 +313,7 @@ def pipeline_runs_wait( 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), ), ) diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 0397333..4d9dd48 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1399,6 +1399,129 @@ def retry_body_factory(attempt, previous_context, error): ] +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_runs.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]: From 07fb6dd533e27987c510b9e3252e9e24d1243a44 Mon Sep 17 00:00:00 2001 From: Volv G Date: Mon, 15 Jun 2026 19:51:49 -0700 Subject: [PATCH 079/111] feat(hydrator): add generic extension seams --- .../src/tangle_cli/pipeline_hydrator.py | 176 ++++++++++++++--- tests/test_pipelines_cli.py | 182 ++++++++++++++++++ 2 files changed, 335 insertions(+), 23 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 9b90e53..5cc3b90 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -42,6 +42,10 @@ 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`.""" @@ -393,6 +397,17 @@ def _uri_scheme(uri: str) -> str | None: return None return uri.split("://", 1)[0] + def _warn_or_raise_hydration_error( + self, + message: str, + exc: Exception | None = None, + ) -> None: + 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, @@ -410,7 +425,20 @@ def _read_uri_text( f"{_available_resolvers_text(self.uri_readers)}" ) hook_context = context or self.make_resolver_context(scheme, uri, kind, None) - return reader(self, uri, hook_context) + 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, @@ -460,17 +488,49 @@ def _resolve_registered_component( if resolver is None: raise self._unsupported_resolver(kind) context = self.make_resolver_context(kind, value, path, base_dir) - return self._call_component_resolver(resolver, value, path, base_dir, context) + 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 - spec = client.get_component_spec(current_digest).data + 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, copy.deepcopy(spec) + return current_digest, spec def _fetch_component_by_digest( self, @@ -480,7 +540,8 @@ def _fetch_component_by_digest( ) -> tuple[str, dict[str, Any]]: """Fetch a component by digest and return as dict.""" self.log.info(f" Fetching component: {digest[:16]}... ({path})") - return self.fetch_component(digest) + resolved_digest, component = self.fetch_component(digest) + return resolved_digest, self.normalize_component_spec(component) def _find_latest_version_component( self, @@ -490,10 +551,10 @@ def _find_latest_version_component( client = self._api_client() def _fetch(digest: str) -> tuple[str, dict[str, Any]]: - spec = client.get_component_spec(digest) - if not spec: + component = client.get_component_spec(digest) + if not component: raise HydrationError(f"Component not found: {digest}") - return digest, copy.deepcopy(spec.data) + return digest, self.normalize_component_spec(component) digests = [c.digest for c in components if c.digest] if not digests: @@ -544,10 +605,34 @@ def _fetch_component_by_url( base_dir: Path | None = None, ) -> tuple[str, dict[str, Any]] | None: """Fetch a component by URL and return as dict.""" - scheme = url.split("://", 1)[0] if "://" in url else "url" - if scheme in self.uri_readers: - return self._fetch_component_from_uri(url, path, base_dir) - return self._resolve_registered_component(scheme, url, path, base_dir) + scheme = self._uri_scheme(url) + try: + if scheme is None: + if base_dir is None: + 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 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, @@ -594,13 +679,24 @@ def _fetch_component_from_uri( try: spec = yaml.safe_load(yaml_text) except yaml.YAMLError as exc: - raise HydrationError(f"Failed to parse component YAML from {url}: {exc}") from 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): - raise HydrationError(f"Component YAML at {url} must be a mapping") + 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: - raise UnsupportedHydrationFeatureError( - f"template_file configs are not supported for non-local URI {url!r}" + 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')} " @@ -645,6 +741,23 @@ def _render_template_config( 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, @@ -675,6 +788,7 @@ def _fetch_component_from_file_url( 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: @@ -685,6 +799,7 @@ def _fetch_component_from_file_url( 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 @@ -692,6 +807,17 @@ def _fetch_component_from_file_url( # 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( @@ -741,7 +867,10 @@ def _fetch_component_by_resolve_url( try: text = path_obj.read_text(encoding="utf-8") except Exception as exc: - raise HydrationError(f"Error reading resolve config {path_obj}: {exc}") from 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) @@ -750,7 +879,10 @@ def _fetch_component_by_resolve_url( self.log.warn(f" ⚠️ Resolve config {source}: unset variable {exc}") return None except Exception as exc: - raise HydrationError(f"Error parsing resolve config {source}: {exc}") from 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: @@ -1034,10 +1166,8 @@ def _filter_by_annotations( if getattr(response, "status_code", None) == 404: continue raise - spec_data = getattr(spec_obj, "data", spec_obj) - annotations = {} - if isinstance(spec_data, dict): - annotations = spec_data.get("metadata", {}).get("annotations", {}) or {} + spec_data = self.normalize_component_spec(spec_obj) + annotations = spec_data.get("metadata", {}).get("annotations", {}) or {} attr_annotations = getattr(spec_obj, "annotations", None) if attr_annotations: annotations = attr_annotations @@ -1102,7 +1232,7 @@ def _resolve_path(p: str | Path | None) -> Path | None: trusted_sources=self.trusted_python_sources, allow_all=self.allow_all_hydration, ): - raise HydrationError(trusted_python_source_guidance(python_file)) + raise UntrustedHydrationSourceError(trusted_python_source_guidance(python_file)) output_folder = _resolve_path(gen_config.get("output_folder")) if output_folder is None: diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 8eca648..90bb633 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -1185,6 +1185,188 @@ def writer(hydrator, uri, content, context): 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 From b95d065f0fb8731537b39f88787ae23b25df88e2 Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 16 Jun 2026 19:00:03 -0700 Subject: [PATCH 080/111] refactor(tangle-cli): simplify runner preparation hooks --- .../src/tangle_cli/pipeline_runner.py | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 7cf2473..096d8e0 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -283,7 +283,7 @@ def format_run_result( @dataclass -class PipelineRunner(PipelineRunManager): +class PipelineRunner(PipelineRunnerHooks, PipelineRunManager): """Generic high-level pipeline runner orchestration.""" hooks: PipelineRunnerHooks = field(default_factory=PipelineRunnerHooks) @@ -294,6 +294,17 @@ def _ensure_mapping(value: Any) -> dict[str, Any]: raise PipelineRunError("pipeline spec must be a mapping") return value + 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, @@ -308,13 +319,14 @@ def prepare_pipeline_for_run( ) -> PipelinePreparationResult: """Load/hydrate/validate/layout a pipeline before submission.""" - pipeline_name = self.hooks.initial_pipeline_name(pipeline_path) + 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: - pipeline_spec, hydrated_effective_path = self.hooks.hydrate_pipeline_for_run( + pipeline_spec, hydrated_effective_path = hooks.hydrate_pipeline_for_run( pipeline_path, client=self.client, resolution_overrides=resolution_overrides, @@ -322,14 +334,14 @@ def prepare_pipeline_for_run( if hydrated_effective_path is not None: effective_path = hydrated_effective_path else: - pipeline_spec = self.hooks.load_pipeline(pipeline_path) + 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 = self.hooks.prepare_loaded_pipeline_spec( + pipeline_spec = hooks.prepare_loaded_pipeline_spec( pipeline_spec, pipeline_path=pipeline_path, effective_path=effective_path, @@ -341,7 +353,7 @@ def prepare_pipeline_for_run( if isinstance(spec_name, str) and spec_name: pipeline_name = spec_name - if self.hooks.should_apply_layout( + if hooks.should_apply_layout( pipeline_spec, pipeline_path=pipeline_path, effective_path=effective_path, @@ -349,7 +361,7 @@ def prepare_pipeline_for_run( force_layout=force_layout, layout_algorithm=layout_algorithm, ): - pipeline_spec = self.hooks.apply_layout( + pipeline_spec = hooks.apply_layout( pipeline_spec, pipeline_path=pipeline_path, effective_path=effective_path, @@ -361,7 +373,7 @@ def prepare_pipeline_for_run( if isinstance(spec_name, str) and spec_name: pipeline_name = spec_name - validation_errors = self.hooks.validate_pipeline_for_run( + validation_errors = hooks.validate_pipeline_for_run( pipeline_spec, pipeline_path=pipeline_path, effective_path=effective_path, @@ -370,7 +382,7 @@ def prepare_pipeline_for_run( if validation_errors and not skip_validation: raise PipelineRunError("Pipeline validation failed:\n - " + "\n - ".join(validation_errors)) - pipeline_spec = self.hooks.before_submit_pipeline_spec( + pipeline_spec = hooks.before_submit_pipeline_spec( pipeline_spec, pipeline_path=pipeline_path, effective_path=effective_path, @@ -389,7 +401,7 @@ def prepare_pipeline_for_run( return preparation except Exception as exc: cleanup_spec = pipeline_spec if isinstance(pipeline_spec, dict) else {} - self.hooks.cleanup_prepared_pipeline( + hooks.cleanup_prepared_pipeline( preparation or PipelinePreparationResult( pipeline_spec=cleanup_spec, @@ -467,6 +479,7 @@ def run_pipeline( """ 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] = {} def prepare_attempt(attempt: int) -> PipelinePreparationResult: @@ -504,7 +517,7 @@ def metadata_factory( _error: Exception | None, ) -> dict[str, Any]: preparation = preparations[attempt] - return self.hooks.metadata_for_run( + return hooks.metadata_for_run( pipeline_name=preparation.pipeline_name, pipeline_path=pipeline_path, effective_path=preparation.effective_path, @@ -534,10 +547,10 @@ def metadata_factory( ) context = result.get("context") attempt = context.attempt if isinstance(context, PipelineRunContext) else max(preparations) - return self.hooks.format_run_result(result, preparation=preparations[attempt]) + return hooks.format_run_result(result, preparation=preparations[attempt]) except Exception as exc: error = exc raise finally: for preparation in preparations.values(): - self.hooks.cleanup_prepared_pipeline(preparation, error=error) + hooks.cleanup_prepared_pipeline(preparation, error=error) From c5d1d263ddb4b0cc5e869302a82424817b81a793 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 18 Jun 2026 07:45:55 -0700 Subject: [PATCH 081/111] refactor(tangle-cli): clarify runner lifecycle outcomes Assisted-By: devx/ba534530-11db-45d2-8203-44aea4c12956 --- .../src/tangle_cli/component_from_func.py | 9 +- packages/tangle-cli/src/tangle_cli/logger.py | 5 +- .../src/tangle_cli/pipeline_hydrator.py | 83 ++-- ...peline_runs.py => pipeline_run_manager.py} | 391 ++++++++++++++++-- .../src/tangle_cli/pipeline_runner.py | 50 ++- .../src/tangle_cli/pipeline_runs_cli.py | 6 +- tests/test_pipeline_runs_cli.py | 121 +++++- 7 files changed, 585 insertions(+), 80 deletions(-) rename packages/tangle-cli/src/tangle_cli/{pipeline_runs.py => pipeline_run_manager.py} (77%) diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index bb27793..f4cb2f4 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -746,13 +746,16 @@ def _is_main_str(n: ast.expr) -> bool: # ============================================================================ -# Authoring-construct stripping (authoring imports + @task/@pipeline/@subpipeline) +# 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). -_AUTHORING_DECORATOR_NAMES = frozenset({"task", "pipeline", "subpipeline"}) +# _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 diff --git a/packages/tangle-cli/src/tangle_cli/logger.py b/packages/tangle-cli/src/tangle_cli/logger.py index 3d8789d..d6b1d03 100644 --- a/packages/tangle-cli/src/tangle_cli/logger.py +++ b/packages/tangle-cli/src/tangle_cli/logger.py @@ -87,8 +87,9 @@ def get_default_logger() -> ConsoleLogger: return _default_logger -# Valid log_type values for CLI commands -CliLogType = str # Valid values: "console", "none", "file" (Literal not supported by typer) +# 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): diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 5cc3b90..6b51a16 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -402,6 +402,8 @@ def _warn_or_raise_hydration_error( message: str, exc: Exception | None = None, ) -> None: + if isinstance(exc, UntrustedHydrationSourceError): + raise exc if self.error_policy == "raise": if isinstance(exc, HydrationError): raise exc @@ -606,27 +608,36 @@ def _fetch_component_by_url( ) -> tuple[str, dict[str, Any]] | None: """Fetch a component by URL and return as dict.""" scheme = self._uri_scheme(url) - try: - if scheme is None: - if base_dir is None: - 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 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 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 @@ -1166,11 +1177,24 @@ def _filter_by_annotations( if getattr(response, "status_code", None) == 404: continue raise - spec_data = self.normalize_component_spec(spec_obj) - annotations = spec_data.get("metadata", {}).get("annotations", {}) or {} 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 @@ -1699,6 +1723,17 @@ def _resolve_http( 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, @@ -1726,3 +1761,5 @@ def _resolve_local_from_python( 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_runs.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py similarity index 77% rename from packages/tangle-cli/src/tangle_cli/pipeline_runs.py rename to packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 1d6c21a..f29994d 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -40,6 +40,194 @@ class UnsupportedPipelineRunFeatureError(PipelineRunError): """Raised for TD extension points intentionally unsupported in OSS defaults.""" +@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 in {"SUCCEEDED", "ENDED"}: + 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. @@ -47,6 +235,28 @@ class PipelineRunContext: 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 @@ -58,6 +268,10 @@ class PipelineRunContext: 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) @@ -278,6 +492,17 @@ def on_early_exit_before_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. @@ -443,6 +668,23 @@ def sanitize_submit_payload(value: Any) -> Any: 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) @@ -592,7 +834,7 @@ def prepare_pipeline_spec_for_submit( hydrate=hydrate, ) - def build_submit_body_from_spec( + def prepare_submit_payload_from_spec( self, pipeline_spec: dict[str, Any], *, @@ -601,8 +843,14 @@ def build_submit_body_from_spec( 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.""" + ) -> 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, @@ -610,10 +858,17 @@ def build_submit_body_from_spec( run_args=run_args, hydrate=hydrate, ) - run_args = self.hooks.prepare_run_arguments(prepared_spec, run_args) - prepared_spec = self.apply_run_name_template(prepared_spec, run_args) - payload = self.convert_yaml_to_payload(copy.deepcopy(prepared_spec), run_args) + 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, @@ -621,9 +876,38 @@ def build_submit_body_from_spec( ) if annotations: submit_annotations.update({str(k): str(v) for k, v in annotations.items()}) - return {"root_task": payload["root_task"], "annotations": submit_annotations} + 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( + 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, *, @@ -632,13 +916,13 @@ def build_submit_body( hydrate: bool = True, run_as: str | None = None, resolution_overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: + ) -> PipelineSubmitPayload: pipeline_spec = self.load_pipeline_for_submit( pipeline_path, hydrate=hydrate, resolution_overrides=resolution_overrides, ) - return self.build_submit_body_from_spec( + return self.prepare_submit_payload_from_spec( pipeline_spec, run_args=run_args, annotations=annotations, @@ -647,6 +931,25 @@ def build_submit_body( 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], @@ -659,7 +962,7 @@ def response_run_context( 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=str(run_name) if run_name 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 @@ -681,15 +984,15 @@ def submit_prepared_body( attempt: int = 1, context: PipelineRunContext | None = None, ) -> 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, ) - submit_context.run_name = ( - str(pipeline_spec.get("name")) if isinstance(pipeline_spec, dict) else None - ) + 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 @@ -717,6 +1020,24 @@ def submit_prepared_body( 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], @@ -728,7 +1049,7 @@ def submit_pipeline_spec( hydrate: bool = True, attempt: int = 1, ) -> dict[str, Any]: - body = self.build_submit_body_from_spec( + payload = self.prepare_submit_payload_from_spec( pipeline_spec, run_args=run_args, annotations=annotations, @@ -736,7 +1057,7 @@ def submit_pipeline_spec( run_as=run_as, hydrate=hydrate, ) - return self.submit_prepared_body(body, pipeline_path=pipeline_path, attempt=attempt) + return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt) def submit_pipeline( self, @@ -749,7 +1070,7 @@ def submit_pipeline( resolution_overrides: dict[str, Any] | None = None, attempt: int = 1, ) -> dict[str, Any]: - body = self.build_submit_body( + payload = self.prepare_submit_payload( pipeline_path, run_args=run_args, annotations=annotations, @@ -757,7 +1078,7 @@ def submit_pipeline( run_as=run_as, resolution_overrides=resolution_overrides, ) - return self.submit_prepared_body(body, pipeline_path=pipeline_path, attempt=attempt) + 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( @@ -975,18 +1296,21 @@ def wait_for_completion( 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: @@ -1018,6 +1342,14 @@ def _wait_metadata( 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, @@ -1066,6 +1398,8 @@ def _run_body_factory( 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 @@ -1073,16 +1407,24 @@ def _run_body_factory( error: Exception | None = None retry_requested = False body = body_factory(attempt, previous_context, last_error) + self.normalize_submit_body_in_place(body) 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 and context.pipeline_spec.get("name") is not None: - context.run_name = str(context.pipeline_spec["name"]) + 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: @@ -1092,7 +1434,6 @@ def _run_body_factory( attempt=attempt, context=context, ) - previous_context = context if attempt > 1: self.hooks.after_retry_submit(context) result: dict[str, Any] @@ -1215,14 +1556,14 @@ def body_factory( _previous_context: PipelineRunContext | None, _error: Exception | None, ) -> dict[str, Any]: - return self.build_submit_body_from_spec( + 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, @@ -1269,14 +1610,14 @@ def body_factory( _previous_context: PipelineRunContext | None, _error: Exception | None, ) -> dict[str, Any]: - return self.build_submit_body( + 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, diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 096d8e0..0d83e2b 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -2,7 +2,7 @@ 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_runs`. +layout/validate, then submit/wait/retry through :mod:`tangle_cli.pipeline_run_manager`. Downstream-specific behavior (Shopify auth, gs:// I/O, Slack/Observe, mutexes, schedulers, service-account annotations, and legacy result shapes) is exposed as hooks rather than imported here. @@ -15,10 +15,14 @@ from pathlib import Path from typing import Any, Mapping -from .pipeline_runs import PipelineRunContext, PipelineRunError, PipelineRunHooks, PipelineRunManager - -_SUCCESS_STATUSES = {"SUCCEEDED"} -_FAILURE_STATUSES = {"FAILED", "SYSTEM_ERROR", "CANCELLED", "CANCELED", "SKIPPED", "INVALID"} +from .pipeline_run_manager import ( + PipelineRunContext, + PipelineRunError, + PipelineRunHooks, + PipelineRunManager, + PipelineSubmitPayload, + PipelineWaitOutcome, +) @dataclass(frozen=True) @@ -262,20 +266,22 @@ def format_run_result( success: bool | None = True if wait_result is not None: status = str(wait_result.get("status") or "unknown") - status_upper = status.upper() - if wait_result.get("timed_out"): - success = None - elif status_upper in _SUCCESS_STATUSES: - success = True - elif status_upper in _FAILURE_STATUSES: - success = False - else: - success = None + 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": preparation.pipeline_name, + "pipeline_name": result_pipeline_name, "run_id": run_id, "root_execution_id": root_execution_id, "preparation": preparation, @@ -424,7 +430,7 @@ def submit_pipeline_spec_result( ) -> dict[str, Any]: """Submit an already prepared spec and return a normalized summary.""" - body = self.build_submit_body_from_spec( + submit_payload = self.prepare_submit_payload_from_spec( copy.deepcopy(pipeline_spec), run_args=run_args, annotations=annotations, @@ -432,7 +438,7 @@ def submit_pipeline_spec_result( run_as=run_as, hydrate=False, ) - response = self.submit_prepared_body(body, pipeline_path=pipeline_path) + 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 @@ -440,7 +446,7 @@ def submit_pipeline_spec_result( return { "success": True, "status": "submitted", - "pipeline_name": pipeline_name, + "pipeline_name": submit_payload.run_name or pipeline_name, "run_id": run_id, "root_execution_id": root_execution_id, "response": response, @@ -481,6 +487,7 @@ def run_pipeline( 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( @@ -502,7 +509,7 @@ def body_factory( _error: Exception | None, ) -> dict[str, Any]: preparation = prepare_attempt(attempt) - return self.build_submit_body_from_spec( + submit_payload = self.prepare_submit_payload_from_spec( copy.deepcopy(preparation.pipeline_spec), run_args=run_args, annotations=annotations, @@ -510,6 +517,8 @@ def body_factory( run_as=run_as, hydrate=False, ) + submit_payloads[attempt] = submit_payload + return submit_payload.to_body() def metadata_factory( attempt: int, @@ -517,8 +526,9 @@ def metadata_factory( _error: Exception | None, ) -> dict[str, Any]: preparation = preparations[attempt] + submit_payload = submit_payloads.get(attempt) return hooks.metadata_for_run( - pipeline_name=preparation.pipeline_name, + 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, diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index efe7a2d..fc254f1 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -10,8 +10,8 @@ from .args_container import ArgsContainer from .cli_helpers import ( - api_arg_specs, LazyTangleApiClient, + api_arg_specs, include_env_credentials_for_args, load_args_or_exit, optional_path, @@ -26,14 +26,14 @@ TokenOption, ) from .logger import Logger, logger_for_log_type -from .pipeline_run_search import normalize_query_input, parse_annotation -from .pipeline_runs import ( +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.") diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 4d9dd48..73949aa 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -12,7 +12,14 @@ from tangle_cli import cli, pipeline_runs_cli from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks -from tangle_cli.pipeline_runs import PipelineRunContext, PipelineRunHooks, PipelineRunManager, PipelineRunError +from tangle_cli.pipeline_run_manager import ( + PipelineRunContext, + PipelineRunHooks, + PipelineRunManager, + PipelineRunError, + PipelineWaitOutcome, + PipelineWaitPoll, +) def run_app(app, args: list[str]) -> None: @@ -920,6 +927,112 @@ def executions_graph_execution_state(self, id: str) -> dict[str, Any]: } +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_graph_state_counts_supports_mapping_like_objects() -> None: assert PipelineRunManager.status_counts_from_graph_state( SimpleNamespace(status_totals={"SUCCEEDED": 1}) @@ -1470,7 +1583,7 @@ def executions_graph_execution_state(self, id: str) -> dict[str, Any]: manager = PipelineRunManager(client=FailedGraphClient()) sleeps: list[float] = [] - monkeypatch.setattr("tangle_cli.pipeline_runs.time.sleep", lambda value: sleeps.append(value)) + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) result = manager.wait_for_completion( "run-1", @@ -1568,7 +1681,7 @@ 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_runs.time.sleep", lambda value: sleeps.append(value)) + 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) @@ -1630,7 +1743,7 @@ def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[ assert result["success"] is True assert result["status"] == "SUCCEEDED" - assert result["pipeline_name"] == "Demo Pipeline" + 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"} From ef7c3b141987799ab158e27cb1759c95ff8babe5 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 18 Jun 2026 11:14:19 -0700 Subject: [PATCH 082/111] fix(tangle-cli): keep ambiguous ended waits unknown Assisted-By: devx/ba534530-11db-45d2-8203-44aea4c12956 --- .../src/tangle_cli/pipeline_run_manager.py | 2 +- tests/test_pipeline_runs_cli.py | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index f29994d..439deb2 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -105,7 +105,7 @@ def success(self) -> bool | 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 in {"SUCCEEDED", "ENDED"}: + if status == "SUCCEEDED": return True if status in _TERMINAL_STATUSES: return False diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 73949aa..ebcd859 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1033,6 +1033,49 @@ def test_pipeline_runs_wait_outcome_from_wait_result_derives_counts_from_status_ 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}) From e351b1bbb3bc5c435439bf673f48e0204990f03b Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 23 Jun 2026 19:36:04 -0700 Subject: [PATCH 083/111] feat(tangle-cli): support dehydrated run exports Assisted-By: devx/5dfdd533-3d6e-420c-b8a0-988698848736 --- .../src/tangle_cli/pipeline_dehydrator.py | 64 +++++-- .../src/tangle_cli/pipeline_run_manager.py | 44 ++++- .../src/tangle_cli/pipeline_runs_cli.py | 10 +- tests/test_pipeline_dehydrator.py | 70 +++++++ tests/test_pipeline_runs_cli.py | 179 ++++++++++++++++++ 5 files changed, 344 insertions(+), 23 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py index 443a2a5..ee5f5a2 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py @@ -23,10 +23,10 @@ import yaml from . import utils +from .api_transport import DEFAULT_TIMEOUT_SECONDS 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. @@ -83,9 +83,22 @@ def __init__( logger: Logger | None = None, component_extension: str | 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, uri_readers: Mapping[str, UriReader] | None = None, uri_writers: Mapping[str, UriWriter] | None = None, ) -> None: + self.client = client + self._client_options = { + "base_url": base_url, + "token": token, + "auth_header": auth_header, + "header": header, + "include_env_credentials": include_env_credentials, + } self.remembered_choices = dict(remembered_choices or {}) self.output_file = output_file self.log = logger or get_default_logger() @@ -100,16 +113,30 @@ def __init__( self.components_dir = Path("components") self.interactive = interactive - self.client = client 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=base_url, + token=token, + auth_header=auth_header, + header=header, + include_env_credentials=include_env_credentials, uri_readers=uri_readers, uri_writers=uri_writers, ) + def _api_client(self) -> Any: + if self.client is None: + from . import client as client_module + + self.client = client_module.TangleApiClient( + timeout=DEFAULT_TIMEOUT_SECONDS, + **self._client_options, + ) + return self.client + def _is_auto_mode(self) -> bool: """Return True when any remembered choice asks for auto mode.""" @@ -210,16 +237,14 @@ def _auto_dehydrate_choice( if not resolved_digest or resolved_digest == "unknown": self.log.info(" Auto: no digest -> file") return "file" - if self.client is not None: - try: - self.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" - self.log.info(" Auto: no API client provided -> file") - return "file" + try: + client = self._api_client() + client.get_component_spec(resolved_digest) + self.log.info(f" Auto: digest {resolved_digest[:16]} found in library -> digest ref") + return "digest" + except (Exception, SystemExit): + 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}") @@ -341,7 +366,10 @@ def _save_component_to_file(self, name: str, digest: str, spec: dict[str, Any]) 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 = Path(str(destination)[7:] if str(destination).startswith("file://") else str(destination)).resolve() + 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]) @@ -418,7 +446,10 @@ def _extract_subgraphs_to_files(self, data: dict[str, Any]) -> dict[str, Any]: self._current_reference_file = original_ref if self._is_local_destination(destination): - destination_path = Path(str(destination)[7:] if str(destination).startswith("file://") else str(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()}" @@ -431,7 +462,10 @@ def _extract_subgraphs_to_files(self, data: dict[str, Any]) -> dict[str, Any]: component_ref["url"] = component_url if self.output_file and self._is_local_destination(self.output_file): - output_path = Path(str(self.output_file)[7:] if str(self.output_file).startswith("file://") else str(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 diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 439deb2..3f54520 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -22,6 +22,7 @@ import yaml from .logger import Logger, get_default_logger +from .pipeline_dehydrator import DehydrateChoice, PipelineDehydrator from .pipeline_hydrator import HydrationError, PipelineHydrator from .pipeline_run_details import get_graph_state_output, get_run_details_output from .pipeline_run_search import search_pipeline_runs @@ -1176,7 +1177,13 @@ def annotations_delete(self, run_id: str, key: str) -> dict[str, Any]: self.client.pipeline_runs_delete_annotations(run_id, key) return {"id": run_id, "key": key, "deleted": True} - def export_run(self, run_id: str, output: str | Path | None = None) -> dict[str, Any]: + 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}") @@ -1188,15 +1195,37 @@ def export_run(self, run_id: str, output: str | Path | None = None) -> dict[str, 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): + 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, + ).dehydrate(spec) content = dump_yaml(spec) if output is None: - return {"run_id": run_id, "pipeline": spec, "yaml": content} + 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") - return {"run_id": run_id, "output": str(output_path)} + + 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 _poll_run_status( self, @@ -1646,10 +1675,13 @@ def parse_key_value_entries(entries: list[str] | None) -> dict[str, str]: return parsed -def parse_json_or_key_values(text: str | None, entries: list[str] | None = None) -> dict[str, Any]: +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 = json.loads(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) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index fc254f1..4ddddcc 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -154,6 +154,7 @@ def pipeline_runs_submit( "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), @@ -166,7 +167,7 @@ def pipeline_runs_submit( def action(manager: PipelineRunManager, args: ArgsContainer) -> dict[str, Any]: kwargs = { - "run_args": parse_json_or_key_values(args.args_json, args.arg), + "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, @@ -460,6 +461,10 @@ 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, @@ -471,12 +476,13 @@ def pipeline_runs_export( 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) + 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 diff --git a/tests/test_pipeline_dehydrator.py b/tests/test_pipeline_dehydrator.py index f6f60d0..2edb658 100644 --- a/tests/test_pipeline_dehydrator.py +++ b/tests/test_pipeline_dehydrator.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Any +import pytest import yaml from tangle_cli import utils @@ -64,6 +65,75 @@ def test_pipeline_dehydrator_replaces_refs_by_explicit_choice(tmp_path: Path) -> 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_api_client(_self): + raise AssertionError("client should not be created") + + monkeypatch.setattr(PipelineDehydrator, "_api_client", fail_api_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 _api_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( { diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index ebcd859..d4e066d 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -105,6 +105,9 @@ def get_run_pipeline_spec(self, run_id: str) -> Any: 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() @@ -163,6 +166,25 @@ def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path, 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( @@ -489,6 +511,163 @@ def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: 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_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]: From 92ca49e676468e43ae15654ce121a1a9d19335c7 Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 23 Jun 2026 19:47:43 -0700 Subject: [PATCH 084/111] refactor(tangle-cli): sync shared CLI seams Assisted-By: devx/5dfdd533-3d6e-420c-b8a0-988698848736 --- .../src/tangle_cli/component_from_func.py | 2 +- .../src/tangle_cli/component_generator.py | 356 +++++++---- .../src/tangle_cli/component_publisher.py | 553 +++++++++--------- .../src/tangle_cli/components_cli.py | 9 +- packages/tangle-cli/src/tangle_cli/handler.py | 96 +++ .../src/tangle_cli/pipeline_dehydrator.py | 50 +- .../src/tangle_cli/pipeline_hydrator.py | 70 ++- .../src/tangle_cli/pipeline_run_manager.py | 45 +- .../src/tangle_cli/pipeline_runner.py | 42 +- .../tangle_cli/published_components_cli.py | 4 +- .../src/tangle_cli/version_manager.py | 5 +- tests/test_pipeline_dehydrator.py | 6 +- tests/test_pipeline_runs_cli.py | 18 +- 13 files changed, 781 insertions(+), 475 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/handler.py diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index f4cb2f4..c476fb9 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -223,7 +223,7 @@ def _ensure_tangle_deploy_authoring_shim() -> None: 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"): + 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) diff --git a/packages/tangle-cli/src/tangle_cli/component_generator.py b/packages/tangle-cli/src/tangle_cli/component_generator.py index 0342190..4a4fafc 100644 --- a/packages/tangle-cli/src/tangle_cli/component_generator.py +++ b/packages/tangle-cli/src/tangle_cli/component_generator.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Literal import yaml @@ -14,34 +14,233 @@ DEFAULT_CONTAINER_IMAGE = "python:3.12@sha256:b8163b64b37051de76577219aa4d5e9b95dc12a2e6c8cb438793c7adb3026016" -def find_dependencies_file(python_file: Path) -> Path | None: - """Find a dependency file for a Python component source file. +class ComponentGenerator: + """Generic Python-function component generation orchestration. - 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. + 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. """ - 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 + 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( @@ -52,33 +251,12 @@ def determine_output_path( ) -> 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(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 + return ComponentGenerator().determine_output_path( + input_file, + output, + output_is_dir, + use_legacy_naming, + ) def regenerate_yaml( @@ -97,82 +275,22 @@ def regenerate_yaml( ) -> bool: """Regenerate a YAML component from a Python function source file.""" - log = logger.info if logger is not None else (print if verbose else lambda *args, **kwargs: None) - if not python_file.exists(): - log(f" ❌ File not found: {python_file}") - return False - - final_output = output_path or determine_output_path(python_file) - image = image or _extract_image_from_yaml(final_output) or DEFAULT_CONTAINER_IMAGE - deps_file = dependencies_from or find_dependencies_file(python_file) - if deps_file: - log(f" Found dependencies: {deps_file}") - - final_output.parent.mkdir(parents=True, exist_ok=True) - return _run_generation( + return ComponentGenerator(logger=logger, verbose=verbose).regenerate_yaml( python_file=python_file, - final_output=final_output, - image=image, - func_name=function_name, - deps_file=deps_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, - log=log, mode=mode, resolve_root=resolve_root, ) -def _run_generation( - *, - 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, - log: Any, - mode: str = "inline", - resolve_root: Path | None = None, -) -> bool: - """Execute component generation and clean up partial output on failure.""" - - try: - from tangle_cli.component_from_func import generate_component_yaml - - log(f" Generating component from {python_file.name}{f' function {func_name!r}' if func_name else ''}...") - success = 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, - ) - if not success: - log(" ❌ Failed to generate component") - return False - log(f" ✅ Generated: {final_output}") - return True - except Exception as exc: - if exc.__class__.__name__ == "AuthoringStripError": - if final_output.exists(): - final_output.unlink() - raise - log(f" ❌ Error: {exc}") - if final_output.exists(): - final_output.unlink() - return False - - __all__ = [ + "ComponentGenerator", "DEFAULT_CONTAINER_IMAGE", "determine_output_path", "find_dependencies_file", diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index f9074f3..2a44d7d 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -18,7 +18,8 @@ import tangle_cli.utils as utils -from .logger import Logger, get_default_logger +from .handler import TangleCliHandler +from .logger import Logger if TYPE_CHECKING: from tangle_api.generated.models import ComponentSpec @@ -114,165 +115,16 @@ def after_batch( ) -> None: ... -# ============================================================================ -# Tangle API Functions -# ============================================================================ - - -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. - - ``existing_components`` must already be owner-scoped by the caller. This - function refuses to operate without a client and skips the newly published - digest to avoid self-deprecation. - """ - - log = logger or get_default_logger() - - if not existing_components: - return 0 - if not client: - log.warn(" ⚠️ Cannot deprecate components without TangleApiClient") - return 0 - - log.info(f" Deprecating {len(existing_components)} previous version(s)...") - deprecation_count = 0 - - for old_component in existing_components: - old_digest = _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 - log.info(f" ✅ Successfully deprecated component {old_digest[:16]}...") - else: - log.warn(f" ⚠️ No response from deprecation request for component {old_digest[:16]}...") - except Exception as exc: - log.warn(f" ⚠️ Warning: Failed to deprecate component {old_digest[:16]}...: {exc}") - - if deprecation_count > 0: - log.info(f" ✅ Deprecated {deprecation_count} old version(s)") - - return deprecation_count - - -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. - - 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. - """ - - log = logger or get_default_logger() - local_version = spec.version - log.info(f" Local version: {local_version}") - - latest_version = None - - if dry_run: - test_version = os.environ.get("TEST_LATEST_VERSION") - if test_version: - latest_version = test_version - log.info(f" Remote version (test): {latest_version}") - else: - 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 = published_by or _current_user_id(client) - if not filter_by: - 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 = _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: - log.warn(f" Warning: Failed to get version for component {digest[:16]}: {exc}") - continue - - if latest_version: - log.info(f" Remote version: {latest_version}") - else: - 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 "" - 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, - ) - - 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)", - ) - - # ============================================================================ # Publisher # ============================================================================ -class ComponentPublisher: +class ComponentPublisher(TangleCliHandler): """Publisher for Tangle components.""" + component_spec_model: type[Any] | None = None + def __init__( self, dry_run: bool = False, @@ -286,6 +138,7 @@ def __init__( client_factory: Callable[[], Any] | None = None, hooks: Sequence[ComponentPublishHook] | None = None, logger: Logger | None = None, + base_url: str | None = None, ) -> None: """Initialize the ComponentPublisher. @@ -296,12 +149,15 @@ def __init__( control. """ - self.dry_run = dry_run - self._client = client - self._client_factory = client_factory + 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.log = logger or get_default_logger() self.results: list[tuple[str, ProcessingResult]] = [] git_info = utils.get_git_info(Path.cwd(), logger=self.log) @@ -311,30 +167,231 @@ def __init__( self.git_remote_url = git_remote_url or git_info.get("git_remote_url") self.git_repo = git_repo - def _get_client(self) -> Any | None: - """Get or create a Tangle API client instance. + 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.""" - Downstream packages can either pass ``client_factory`` to the - constructor or subclass and override this method to provide custom auth - and lazy client construction. + 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. """ - if self._client is None and not self.dry_run: - if self._client_factory is not None: - self._client = self._client_factory() - return self._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 component publishing. " - "Install tangle-cli[native] or provide a local tangle_api.generated package." + 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, ) - return None - raise - self._client = TangleApiClient(logger=self.log) - return self._client + 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, @@ -387,7 +444,7 @@ def publish_component( try: path = Path(file_path) - local_yaml_content = path.read_text(encoding="utf-8") + 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( @@ -398,7 +455,7 @@ def publish_component( ) try: - spec = _component_spec_from_yaml(local_yaml_content, annotations=annotations) + 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( @@ -435,13 +492,7 @@ def publish_component( reason="Failed to create TangleApiClient", ) - version_check_result = perform_version_check( - spec=spec, - dry_run=self.dry_run, - client=client, - logger=self.log, - published_by=self.published_by, - ) + 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}") @@ -453,7 +504,7 @@ def publish_component( component_yaml_path = None if self._git_root: try: - component_yaml_path = str(Path(file_path).resolve().relative_to(self._git_root)) + component_yaml_path = str(Path(file_path).resolve().relative_to(Path(self._git_root).resolve())) except ValueError: pass @@ -482,9 +533,11 @@ def publish_component( response={"name": spec.name, "text": local_yaml_content}, ) - filter_by = self.published_by or _current_user_id(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") + 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, @@ -501,7 +554,7 @@ def publish_component( if new_digest: self.log.info(f"✅ Published: {spec.name} (digest: {str(new_digest)[:16]}...)") - deprecate_old_components(existing_components, str(new_digest), client=client, logger=self.log) + self.deprecate_old_components(existing_components, str(new_digest)) return ProcessingResult( outcome=ProcessingOutcome.SUCCESS, local_version=version_check_result.local_version, @@ -689,10 +742,47 @@ def _run_hook(self, method_name: str, *args: Any, context: ComponentPublishConte # ============================================================================ -# Convenience wrapper functions +# 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, @@ -712,12 +802,12 @@ def publish_component_to_tangle( 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, - client=client, - client_factory=client_factory, published_by=published_by, ) return publisher.publish_component( @@ -762,17 +852,6 @@ def deprecate_component( ) -def load_component_spec( - component_path: str | Path, - *, - annotations: Mapping[str, str] | None = None, -) -> "ComponentSpec": - """Load a component YAML file into the generated ``ComponentSpec`` model.""" - - text = Path(component_path).read_text(encoding="utf-8") - return _component_spec_from_yaml(text, annotations=annotations) - - def prepare_component_for_publish( component_path: str | Path, *, @@ -787,27 +866,18 @@ def prepare_component_for_publish( ) -> "ComponentSpec": """Load and apply generic publish-time overrides/metadata.""" - path = Path(component_path) - spec = load_component_spec(path, annotations=annotations) - if name: - spec.name = name - spec.data["name"] = name - if description: - spec.description = description - spec.data["description"] = description - spec.update_fields( + 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, - component_yaml_path=_component_yaml_path(path, git_root), + name=name, + description=description, + annotations=annotations, ) - return spec - - -# ============================================================================ -# Internal helpers -# ============================================================================ def _hook_accepts_context(method: Any) -> bool: @@ -823,47 +893,6 @@ def _hook_accepts_context(method: Any) -> bool: return False -def _component_spec_from_yaml( - yaml_content: str, - *, - annotations: Mapping[str, str] | None = None, -) -> "ComponentSpec": - from tangle_api.generated.models import ComponentSpec - - return ComponentSpec.from_yaml(yaml_content, annotations=dict(annotations or {})) - - -def _component_yaml_path(component_path: Path, git_root: str | Path | None) -> str | None: - if git_root is None: - return None - try: - return str(component_path.resolve().relative_to(Path(git_root).resolve())) - except ValueError: - return None - - -def _component_digest(component: Any) -> str | None: - 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(client: Any) -> str | None: - 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 _to_plain(value: Any) -> Any: if hasattr(value, "to_dict"): return value.to_dict() @@ -884,9 +913,9 @@ def _to_plain(value: Any) -> Any: "ProcessingResult", "deprecate_component", "deprecate_old_components", - "load_component_spec", "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 index 3edc353..3fcf692 100644 --- a/packages/tangle-cli/src/tangle_cli/components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/components_cli.py @@ -123,20 +123,21 @@ def _components_generate_from_python_impl( ) for args in all_args: logger, finalize_logs = logger_for_log_type(args.log_type) - from .component_generator import determine_output_path, regenerate_yaml + 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 = determine_output_path( + output_path = generator.determine_output_path( python_path, args.output, output_is_dir=False, use_legacy_naming=bool(args.use_legacy_naming), ) try: - success = regenerate_yaml( + success = generator.regenerate_yaml( python_file=python_path, output_path=output_path, function_name=args.function_name, @@ -146,8 +147,6 @@ def _components_generate_from_python_impl( strip_code=bool(args.strip_code), mode=selected_mode, resolve_root=args.resolve_root, - verbose=True, - logger=logger, ) if not success: raise SystemExit(1) 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/pipeline_dehydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py index ee5f5a2..39d3aa5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py @@ -23,7 +23,8 @@ import yaml from . import utils -from .api_transport import DEFAULT_TIMEOUT_SECONDS +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 @@ -56,7 +57,7 @@ class DehydrateChoice: AUTO = "a" -class PipelineDehydrator: +class PipelineDehydrator(TangleCliHandler): """Dehydrate pipeline YAML by replacing full component specs with refs. Supported choices: @@ -84,24 +85,16 @@ def __init__( component_extension: str | 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, uri_readers: Mapping[str, UriReader] | None = None, uri_writers: Mapping[str, UriWriter] | None = None, ) -> None: - self.client = client - self._client_options = { - "base_url": base_url, - "token": token, - "auth_header": auth_header, - "header": header, - "include_env_credentials": include_env_credentials, - } + 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.log = logger or get_default_logger() self.component_extension = component_extension or ".yaml" self._components_dir_explicit = components_dir is not None @@ -118,25 +111,11 @@ def __init__( self._io = PipelineHydrator( enable_resolution=False, logger=self.log, - base_url=base_url, - token=token, - auth_header=auth_header, - header=header, - include_env_credentials=include_env_credentials, + base_url=self.base_url, uri_readers=uri_readers, uri_writers=uri_writers, ) - def _api_client(self) -> Any: - if self.client is None: - from . import client as client_module - - self.client = client_module.TangleApiClient( - timeout=DEFAULT_TIMEOUT_SECONDS, - **self._client_options, - ) - return self.client - def _is_auto_mode(self) -> bool: """Return True when any remembered choice asks for auto mode.""" @@ -238,11 +217,18 @@ def _auto_dehydrate_choice( self.log.info(" Auto: no digest -> file") return "file" try: - client = self._api_client() + 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, SystemExit): + except Exception: self.log.info(f" Auto: digest {resolved_digest[:16]} not in library -> file") return "file" diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 6b51a16..072e82a 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -24,9 +24,10 @@ from . import utils from .api_transport import DEFAULT_TIMEOUT_SECONDS -from .component_generator import regenerate_yaml +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, get_default_logger +from .logger import Logger from .utils import add_official_prefix if TYPE_CHECKING: @@ -87,6 +88,17 @@ class ResolverContext: 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. @@ -172,7 +184,7 @@ def include_raw(path: str) -> str: return template.render(**merged_context) -class PipelineHydrator: +class PipelineHydrator(TangleCliHandler): """Hydrates pipeline YAML by resolving component references. This class mirrors TD's ``PipelineHydrator`` shape. Supported generic refs: @@ -199,12 +211,13 @@ def __init__( 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: - self.client = client + super().__init__(client=client, logger=logger, base_url=base_url) self._client_options = { "base_url": base_url, "token": token, @@ -217,7 +230,6 @@ def __init__( self.verbose = verbose self.enable_resolution = enable_resolution self._postprocess_callback = postprocess_task - self.log = logger or get_default_logger() self.component_resolvers: dict[str, ComponentResolver] = dict(COMPONENT_RESOLVERS) if component_resolvers: self.component_resolvers.update(component_resolvers) @@ -227,6 +239,7 @@ def __init__( 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 @@ -237,15 +250,19 @@ def __init__( k: str(v) for k, v in self.resolution_overrides.items() } - def _api_client(self) -> TangleApiClient: - if self.client is None: - from . import client as client_module + def _create_client(self) -> TangleApiClient | None: + from . import client as client_module - self.client = client_module.TangleApiClient( - timeout=DEFAULT_TIMEOUT_SECONDS, - **self._client_options, - ) - return self.client + 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: @@ -1267,18 +1284,21 @@ def _resolve_path(p: str | Path | None) -> Path | None: output_folder.mkdir(parents=True, exist_ok=True) out_path = output_folder / (python_file.stem.replace("_", "-") + ".yaml") - success = regenerate_yaml( - 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)), - verbose=False, - mode=str(gen_config.get("mode", "inline")), - resolve_root=resolve_root, - ) + 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 diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 3f54520..1ed5bdb 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -10,6 +10,7 @@ from __future__ import annotations import copy +import inspect import json import re import time @@ -21,6 +22,7 @@ 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 @@ -321,9 +323,13 @@ def hydrate_pipeline( self, pipeline_path: str | Path, *, - client: Any, 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, @@ -563,10 +569,21 @@ def fetch_logs(self, client: Any, execution_id: str) -> Any: @dataclass -class PipelineRunManager: +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: @@ -769,6 +786,17 @@ def status_from_run(run: Mapping[str, Any]) -> str | None: 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, @@ -777,11 +805,11 @@ def load_pipeline_for_submit( resolution_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: if hydrate: - return self.hooks.hydrate_pipeline( - pipeline_path, - client=self.client, - resolution_overrides=resolution_overrides, - ) + 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 @@ -999,8 +1027,9 @@ def submit_prepared_body( 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(self.client.pipeline_runs_create(body=body)) + response = self.to_plain(client.pipeline_runs_create(body=body)) except Exception as exc: self.hooks.on_submit_error(exc, context=submit_context) raise diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 0d83e2b..e0afa48 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -11,6 +11,7 @@ from __future__ import annotations import copy +import inspect from dataclasses import dataclass, field from pathlib import Path from typing import Any, Mapping @@ -62,7 +63,7 @@ def hydrate_pipeline_for_run( self, pipeline_path: str | Path, *, - client: Any, + 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. @@ -72,11 +73,21 @@ def hydrate_pipeline_for_run( 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, - client=client, - resolution_overrides=resolution_overrides, + **hydrate_kwargs, ), None, ) @@ -294,12 +305,28 @@ class PipelineRunner(PipelineRunnerHooks, PipelineRunManager): 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. @@ -332,10 +359,13 @@ def prepare_pipeline_for_run( preparation: PipelinePreparationResult | None = None try: if hydrate: - pipeline_spec, hydrated_effective_path = hooks.hydrate_pipeline_for_run( + 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, - client=self.client, - resolution_overrides=resolution_overrides, + **hydrate_kwargs, ) if hydrated_effective_path is not None: effective_path = hydrated_effective_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 index 5fc7183..7d8df18 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -8,8 +8,8 @@ from cyclopts import App, Parameter from .cli_helpers import ( - api_arg_specs, LazyTangleApiClient, + api_arg_specs, include_env_credentials_for_args, load_args_or_exit, optional_path, @@ -23,8 +23,8 @@ LogTypeOption, TokenOption, ) -from .logger import logger_for_log_type from .component_publisher import ComponentPublisher, deprecate_component +from .logger import logger_for_log_type def _client_from_options( diff --git a/packages/tangle-cli/src/tangle_cli/version_manager.py b/packages/tangle-cli/src/tangle_cli/version_manager.py index 0c0737a..0860889 100644 --- a/packages/tangle-cli/src/tangle_cli/version_manager.py +++ b/packages/tangle-cli/src/tangle_cli/version_manager.py @@ -12,7 +12,7 @@ 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 regenerate_yaml +from tangle_cli.component_generator import ComponentGenerator from tangle_cli.logger import Logger, get_default_logger ReferenceContentGetter = Callable[[str], str | None] @@ -429,7 +429,7 @@ def bump_version( ) if success: log.info(" 🔄 Regenerating YAML...") - success = regenerate_yaml( + success = ComponentGenerator(logger=log).regenerate_yaml( python_full_path, output_path=yaml_path, function_name=generation_function_name, @@ -438,7 +438,6 @@ def bump_version( strip_code=not has_original_code, mode=generation_mode, resolve_root=resolve_root, - logger=log, ) else: log.error(f"❌ Python source not found: {python_path}") diff --git a/tests/test_pipeline_dehydrator.py b/tests/test_pipeline_dehydrator.py index 2edb658..e9aed29 100644 --- a/tests/test_pipeline_dehydrator.py +++ b/tests/test_pipeline_dehydrator.py @@ -82,10 +82,10 @@ def test_pipeline_dehydrator_auto_with_url_does_not_create_client( ) -> None: """Auto mode should not create a client when canonical URL is enough to decide.""" - def fail_api_client(_self): + def fail_get_client(_self): raise AssertionError("client should not be created") - monkeypatch.setattr(PipelineDehydrator, "_api_client", fail_api_client) + 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) @@ -100,7 +100,7 @@ def test_pipeline_dehydrator_auto_lazily_creates_client_for_library_lookup(tmp_p client = FakeClient({"digest-found"}) class LazyDehydrator(PipelineDehydrator): - def _api_client(self): + def _get_client(self): return client data = _pipeline({"published": _task("Published", "digest-found")}) diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index d4e066d..666c65d 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -858,7 +858,7 @@ def test_pipeline_runs_submit_refuses_untrusted_local_from_python( monkeypatch, tmp_path: Path, ) -> None: - from tangle_cli import pipeline_hydrator as hydrator_module + from tangle_cli.component_generator import ComponentGenerator project_dir = tmp_path / "project" project_dir.mkdir() @@ -868,10 +868,10 @@ def test_pipeline_runs_submit_refuses_untrusted_local_from_python( 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(**kwargs): + def fake_regenerate_yaml(self, **kwargs): raise AssertionError("untrusted local_from_python must be blocked before generation") - monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + 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"): @@ -882,7 +882,7 @@ def test_pipeline_runs_submit_ignores_untrusted_resolve_root_for_python_trust( monkeypatch, tmp_path: Path, ) -> None: - from tangle_cli import pipeline_hydrator as hydrator_module + from tangle_cli.component_generator import ComponentGenerator project_dir = tmp_path / "project" project_dir.mkdir() @@ -896,10 +896,10 @@ def test_pipeline_runs_submit_ignores_untrusted_resolve_root_for_python_trust( resolve_root=str(outside_dir), ) - def fake_regenerate_yaml(**kwargs): + def fake_regenerate_yaml(self, **kwargs): raise AssertionError("untrusted resolve_root must not authorize execution") - monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + 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"): @@ -911,7 +911,7 @@ def test_pipeline_runs_submit_trusted_hydration_allows_untrusted_local_from_pyth tmp_path: Path, capsys, ) -> None: - from tangle_cli import pipeline_hydrator as hydrator_module + from tangle_cli.component_generator import ComponentGenerator project_dir = tmp_path / "project" project_dir.mkdir() @@ -922,7 +922,7 @@ def test_pipeline_runs_submit_trusted_hydration_allows_untrusted_local_from_pyth pipeline_path = _write_submit_local_from_python_pipeline(project_dir, str(outside_python)) regenerated: list[Path] = [] - def fake_regenerate_yaml(**kwargs): + 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", @@ -931,7 +931,7 @@ def fake_regenerate_yaml(**kwargs): return True fake_client = FakeClient() - monkeypatch.setattr(hydrator_module, "regenerate_yaml", fake_regenerate_yaml) + monkeypatch.setattr(ComponentGenerator, "regenerate_yaml", fake_regenerate_yaml) monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) app = cli.build_app() From ac205a712ba9a5a36aec9eb1fa67cbac3cfcf2ff Mon Sep 17 00:00:00 2001 From: Volv G Date: Wed, 24 Jun 2026 13:06:44 -0700 Subject: [PATCH 085/111] fix(tangle-cli): propagate export logger to dehydrator Assisted-By: devx/5dfdd533-3d6e-420c-b8a0-988698848736 --- .../src/tangle_cli/pipeline_run_manager.py | 1 + tests/test_pipeline_runs_cli.py | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 1ed5bdb..5575222 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -1233,6 +1233,7 @@ def export_run( remembered_choices={"": DehydrateChoice.AUTO}, output_file=output, client=self.client, + logger=self.logger, ).dehydrate(spec) content = dump_yaml(spec) if output is None: diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 666c65d..d8acd87 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -10,7 +10,7 @@ import pytest import yaml -from tangle_cli import cli, pipeline_runs_cli +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, @@ -551,6 +551,35 @@ def get_run_pipeline_spec(self, run_id: str) -> Any: 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: From a9c721d1e5c8fb434033c00596a7176d35005488 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 25 Jun 2026 12:09:15 -0700 Subject: [PATCH 086/111] refactor(tangle-cli): align resource managers around class seams Assisted-By: devx/5dfdd533-3d6e-420c-b8a0-988698848736 --- .../tangle-cli/src/tangle_cli/__init__.py | 10 +- .../tangle-cli/src/tangle_cli/artifacts.py | 79 +-- .../src/tangle_cli/artifacts_cli.py | 9 +- packages/tangle-cli/src/tangle_cli/cli.py | 28 +- .../src/tangle_cli/component_inspector.py | 463 +++++++++--------- .../tangle_cli/pipeline_run_annotations.py | 41 ++ .../src/tangle_cli/pipeline_run_details.py | 101 +--- .../src/tangle_cli/pipeline_run_manager.py | 108 +++- .../src/tangle_cli/pipeline_run_search.py | 62 +-- .../src/tangle_cli/pipeline_runs_cli.py | 38 +- .../tangle_cli/published_components_cli.py | 42 +- packages/tangle-cli/src/tangle_cli/secrets.py | 102 +--- .../tangle-cli/src/tangle_cli/secrets_cli.py | 20 +- tests/test_api_cli.py | 44 +- tests/test_artifacts.py | 23 +- tests/test_artifacts_cli.py | 16 +- tests/test_component_inspector.py | 34 +- tests/test_pipeline_runs_cli.py | 4 +- tests/test_resource_managers.py | 33 +- tests/test_tangle_cli_cli.py | 16 + tests/test_tangle_deploy_compat_imports.py | 48 +- 21 files changed, 583 insertions(+), 738 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py create mode 100644 tests/test_tangle_cli_cli.py diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py index 9bbe1bb..49d78ee 100644 --- a/packages/tangle-cli/src/tangle_cli/__init__.py +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -6,6 +6,14 @@ bindings are available. """ +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as metadata_version + from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient -__all__ = ["TangleDynamicDiscoveryClient"] +try: + __version__ = metadata_version("tangle-cli") +except PackageNotFoundError: + __version__ = "0.0.1" + +__all__ = ["TangleDynamicDiscoveryClient", "__version__"] diff --git a/packages/tangle-cli/src/tangle_cli/artifacts.py b/packages/tangle-cli/src/tangle_cli/artifacts.py index 9f6baa0..2a97a26 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -6,10 +6,11 @@ from __future__ import annotations -from collections.abc import Callable 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.""" @@ -80,7 +81,7 @@ def from_response(cls, response: Any, *, key: str = "") -> ArtifactInfo: ) -class ArtifactManager: +class ArtifactManager(TangleCliHandler): """Read-only artifact metadata manager. Downstream packages can inject an already-authenticated client or a lazy @@ -93,18 +94,11 @@ def __init__( self, client: ArtifactClient | None = None, *, - client_factory: Callable[[], ArtifactClient] | None = None, + client_factory: Any | None = None, + logger: Any | None = None, + **kwargs: Any, ) -> None: - self._client = client - self._client_factory = client_factory - - @property - def client(self) -> ArtifactClient: - if self._client is None: - if self._client_factory is None: - raise ValueError("ArtifactManager requires a client or client_factory") - self._client = self._client_factory() - return self._client + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) def collect_artifacts( self, @@ -173,8 +167,9 @@ def collect_execution_artifacts( """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 = self.client.get_execution_details(execution_id) + 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: @@ -211,7 +206,7 @@ def get_artifacts( tasks_query = query.get("tasks", {}) or {} components_query_raw = query.get("components", []) or [] if tasks_query or components_query_raw: - details = self.client.get_run_details(run_id) + 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") @@ -226,7 +221,7 @@ def get_artifacts( artifacts: dict[str, ArtifactInfo] = {} for key, artifact_id in artifact_ids.items(): try: - response = self.client.artifacts_get(artifact_id) + 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)) @@ -244,11 +239,6 @@ def serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, An return result -# --------------------------------------------------------------------------- -# Compatibility helpers and thin module-level wrappers -# --------------------------------------------------------------------------- - - def _mapping_or_attr(value: Any, key: str, default: Any = None) -> Any: if isinstance(value, dict): return value.get(key, default) @@ -295,56 +285,9 @@ def _artifact_info_from_response(response: Any, *, artifact_id: str, key: str) - return ArtifactInfo.from_response(response, key=key) -def _collect_artifacts( - execution: Any, - tasks_query: dict[str, list[str]], - components_query: list[ArtifactComponentQuery], - prefix: str = "", -) -> dict[str, str]: - """Backward-compatible wrapper for :meth:`ArtifactManager.collect_artifacts`.""" - - return ArtifactManager().collect_artifacts(execution, tasks_query, components_query, prefix) - - -def _collect_execution_artifacts( - client: ArtifactClient, - execution_ids: dict[str, list[str]], -) -> dict[str, str]: - """Backward-compatible wrapper for :meth:`ArtifactManager.collect_execution_artifacts`.""" - - return ArtifactManager(client=client).collect_execution_artifacts(execution_ids) - - -def get_artifacts( - run_id: str, - query: dict[str, Any], - client: ArtifactClient, -) -> dict[str, ArtifactInfo]: - """Backward-compatible wrapper for :meth:`ArtifactManager.get_artifacts`.""" - - return ArtifactManager(client=client).get_artifacts(run_id, query) - - -def serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, Any]]: - """Serialize artifact dict to a JSON-friendly list, dropping ``None`` fields.""" - - return ArtifactManager.serialize_artifacts(artifacts) - - -def _serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, Any]]: - """Deprecated compatibility alias; use :func:`serialize_artifacts`.""" - - return serialize_artifacts(artifacts) - - __all__ = [ "ArtifactClient", "ArtifactComponentQuery", "ArtifactInfo", "ArtifactManager", - "_collect_artifacts", - "_collect_execution_artifacts", - "_serialize_artifacts", - "get_artifacts", - "serialize_artifacts", ] diff --git a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py index 2f621ba..a6d59e1 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts_cli.py @@ -7,8 +7,8 @@ from cyclopts import App, Parameter from .cli_helpers import ( - api_arg_specs, LazyTangleApiClient, + api_arg_specs, include_env_credentials_for_args, load_args_or_exit, print_json, @@ -83,10 +83,11 @@ def artifacts_get( ) if require_available := getattr(client, "require_available", None): require_available() - from .artifacts import _serialize_artifacts, get_artifacts + from .artifacts import ArtifactManager + manager = ArtifactManager(client=client) try: - artifacts = get_artifacts(args.run_id, args.query, client=client) + 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 @@ -96,7 +97,7 @@ def artifacts_get( "status": "success", "run_id": args.run_id, "count": len(artifacts), - "artifacts": _serialize_artifacts(artifacts), + "artifacts": ArtifactManager.serialize_artifacts(artifacts), } ) finally: diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py index bc7bbe3..e9c1fd4 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -1,13 +1,22 @@ from cyclopts import App -from . import api_cli -from . import artifacts_cli -from . import components_cli -from . import pipeline_runs_cli -from . import pipelines_cli -from . import published_components_cli -from . import quickstart -from . import secrets_cli +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: @@ -31,8 +40,9 @@ def build_app() -> App: app = App( help="CLI for Tangle, the open-source ML pipeline orchestration platform.", - version="0.0.1", + version=__version__, ) + app.command(name="version")(version) app.command(quickstart.app) app.command(api_cli.build_app()) app.command(build_sdk_app()) diff --git a/packages/tangle-cli/src/tangle_cli/component_inspector.py b/packages/tangle-cli/src/tangle_cli/component_inspector.py index 05dc98f..92f16c5 100644 --- a/packages/tangle-cli/src/tangle_cli/component_inspector.py +++ b/packages/tangle-cli/src/tangle_cli/component_inspector.py @@ -3,15 +3,18 @@ from __future__ import annotations from pathlib import PurePosixPath -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin, urlparse from weakref import WeakKeyDictionary import yaml -from tangle_cli.client import TangleApiClient +from tangle_cli.handler import TangleCliHandler from tangle_cli.models import ComponentInfo, ComponentSpec +if TYPE_CHECKING: + from tangle_cli.client import TangleApiClient + # ============================================================================ # Client helpers # ============================================================================ @@ -35,11 +38,6 @@ def _request_path(client: TangleApiClient, path: str) -> Any: return response -def _is_not_found_error(exc: Exception) -> bool: - response = getattr(exc, "response", None) - return getattr(response, "status_code", None) == 404 - - def _published_components( client: TangleApiClient, *, @@ -198,67 +196,247 @@ def _ensure_library_loaded(client: TangleApiClient) -> _LibraryState: return state -def get_standard_library(client: TangleApiClient) -> dict[str, Any]: - """Return the standard component library organised by folders. +class ComponentInspector(TangleCliHandler): + """Inspector for published component metadata and specs. - Each component entry has a stripped spec (no implementation blocks), an - optional ``url``, and a ``digest``. + 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. """ - library_full, _ = _ensure_library_loaded(client) - return { - "folders": [ + 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": folder.get("name"), - "components": [_strip_entry(comp) for comp in folder.get("components", [])], + "name": comp.name, + "digest": comp.digest, + "version": comp.version, + "deprecated": comp.deprecated, + "description": (comp.description or "")[:200], } - for folder in library_full.get("folders", []) - ], - } + for comp in components + ] + return { + "status": "success", + "query": name, + "count": len(results), + "components": results, + } -# ============================================================================ -# Core functions (usable by wrappers and CLIs) -# ============================================================================ + @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. + """ -_TRANSPARENT_IMAGE_PREFIXES = ("python:", "ubuntu:", "debian:", "alpine:") + ann = spec.annotations + if ann.get("python_original_code"): + return True, "inline Python source code embedded in annotations" -def transparency_check(spec: ComponentSpec) -> tuple[bool, str]: - """Check if a component's definition is transparent (source-inspectable). + canonical = ann.get("canonical_location") + if isinstance(canonical, str) and canonical.startswith(("https://", "http://")): + return True, f"canonical_location annotation points to {canonical}" - 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. - """ + 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']}" - ann = spec.annotations + 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", + ) - if ann.get("python_original_code"): - return True, "inline Python source code embedded in annotations" + return False, "no inline source, canonical location, git metadata, or standard public image found" - 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']}" +# ============================================================================ +# Private function-level implementation helpers +# ============================================================================ - 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" +_TRANSPARENT_IMAGE_PREFIXES = ("python:", "ubuntu:", "debian:", "alpine:") def _resolve_git_source(spec: ComponentSpec) -> dict[str, Any] | None: @@ -306,192 +484,11 @@ def _backfill_version_from_spec(info: ComponentInfo) -> None: info.version = info.component_spec.version -def _enrich_with_spec( - info: ComponentInfo, - client: TangleApiClient, -) -> None: - """Fetch the full component data and attach it to *info*.""" - - if not info.digest: - return - try: - info.component_spec = _get_component_spec(client, info.digest) - _backfill_version_from_spec(info) - except Exception as e: - info.spec_error = str(e) - - def _get_component_spec(client: TangleApiClient, digest: str) -> ComponentSpec | None: try: return client.get_component_spec(digest) except Exception as exc: - if _is_not_found_error(exc): + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) == 404: return None raise - - -def inspect_by_digest( - client: TangleApiClient, - 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. - """ - - 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 = 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( - client: TangleApiClient, - 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.""" - - 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: - _enrich_with_spec(info, client) - entry = info.to_dict(strip_spec=not full_spec) - if info.component_spec: - transparent, transparency_reason = 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( - client: TangleApiClient, - 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( - client, - name_substring=name, - include_deprecated=include_deprecated, - published_by_substring=published_by, - digest=digest, - ) - - results = [] - for comp in components: - results.append({ - "name": comp.name, - "digest": comp.digest, - "version": comp.version, - "deprecated": comp.deprecated, - "description": (comp.description or "")[:200], - }) - - return { - "status": "success", - "query": name, - "count": len(results), - "components": results, - } 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 index 96ba6f4..6f3b20b 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py @@ -7,13 +7,14 @@ from __future__ import annotations -from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as FutureTimeoutError from typing import Any +from .handler import TangleCliHandler -class PipelineRunDetails: + +class PipelineRunDetails(TangleCliHandler): """Resource manager for pipeline run details and graph-state output. Downstream packages can subclass this class or inject a lazy @@ -25,18 +26,11 @@ def __init__( self, client: Any = None, *, - client_factory: Callable[[], Any] | None = None, + client_factory: Any | None = None, + logger: Any | None = None, + **kwargs: Any, ) -> None: - self._client = client - self._client_factory = client_factory - - @property - def client(self) -> Any: - if self._client is None: - if self._client_factory is None: - raise ValueError("PipelineRunDetails requires a client or client_factory") - self._client = self._client_factory() - return self._client + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) @staticmethod def to_plain(value: Any) -> Any: @@ -53,9 +47,9 @@ def serialize_execution(self, execution: Any) -> dict[str, Any]: if component_spec: out["component"] = _value(component_spec, "name", "unknown") or "unknown" try: - from .component_inspector import transparency_check + from .component_inspector import ComponentInspector - transparent, reason = transparency_check(component_spec) + transparent, reason = ComponentInspector.transparency_check(component_spec) out["transparent"] = transparent out["transparency_reason"] = reason except Exception: @@ -128,21 +122,22 @@ def get_run_details_output( kwargs["include_implementations"] = include_implementations if execution_id is not None: kwargs["execution_id"] = execution_id - details = self.client.get_run_details(run_id, **kwargs) + 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.client.pipeline_runs_get(run_id) + run = self._require_client().pipeline_runs_get(run_id) except Exception as exc: - if _status_code(exc) != 404: + 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.client.executions_graph_execution_state(root_execution_id) + state = self._require_client().executions_graph_execution_state(root_execution_id) return { "run_id": run_id, "root_execution_id": root_execution_id, @@ -176,11 +171,6 @@ def get_graph_state_output( return {"results": results} -# --------------------------------------------------------------------------- -# Compatibility helpers and thin module-level wrappers -# --------------------------------------------------------------------------- - - def _value(value: Any, key: str, default: Any = None) -> Any: if isinstance(value, dict): return value.get(key, default) @@ -199,49 +189,6 @@ def _to_plain(value: Any) -> Any: return value -def serialize_execution(execution: Any) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`PipelineRunDetails.serialize_execution`.""" - - return PipelineRunDetails().serialize_execution(execution) - - -def serialize_run_details(details: Any) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`PipelineRunDetails.serialize_run_details`.""" - - return PipelineRunDetails().serialize_run_details(details) - - -def get_run_details_output( - client: Any, - run_id: str, - *, - include_implementations: bool = False, - include_annotations: bool = False, - include_execution_state: bool = False, - execution_id: str | None = None, -) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`PipelineRunDetails.get_run_details_output`.""" - - return PipelineRunDetails(client=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 _status_code(exc: Exception) -> int | None: - response = getattr(exc, "response", None) - return getattr(response, "status_code", None) - - -def fetch_graph_state_one(client: Any, run_id: str) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`PipelineRunDetails.fetch_graph_state_one`.""" - - return PipelineRunDetails(client=client).fetch_graph_state_one(run_id) - - def _error_result(run_id: str, message: str) -> dict[str, Any]: return { "run_id": run_id, @@ -253,22 +200,4 @@ def _error_result(run_id: str, message: str) -> dict[str, Any]: } -def get_graph_state_output( - client: Any, - run_ids: list[str], - *, - timeout: float = 30.0, -) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`PipelineRunDetails.get_graph_state_output`.""" - - return PipelineRunDetails(client=client).get_graph_state_output(run_ids, timeout=timeout) - - -__all__ = [ - "PipelineRunDetails", - "fetch_graph_state_one", - "get_graph_state_output", - "get_run_details_output", - "serialize_execution", - "serialize_run_details", -] +__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 index 5575222..cc1595c 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -26,13 +26,15 @@ from .logger import Logger, get_default_logger from .pipeline_dehydrator import DehydrateChoice, PipelineDehydrator from .pipeline_hydrator import HydrationError, PipelineHydrator -from .pipeline_run_details import get_graph_state_output, get_run_details_output -from .pipeline_run_search import search_pipeline_runs +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" class PipelineRunError(RuntimeError): @@ -290,6 +292,7 @@ class PipelineWaitPoll: 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 @@ -754,6 +757,32 @@ def status_counts_from_graph_state(graph_state: Mapping[str, Any] | Any) -> dict 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: @@ -1127,8 +1156,7 @@ def get_run_details( include_implementations: bool = False, execution_id: str | None = None, ) -> dict[str, Any]: - return get_run_details_output( - self.client, + return PipelineRunDetails(client=self.client).get_run_details_output( run_id, include_implementations=include_implementations, include_annotations=include_annotations, @@ -1144,7 +1172,7 @@ def graph_state(self, execution_id: str) -> Mapping[str, Any] | Any: return self.to_plain(graph_state) def graph_state_output(self, run_ids: list[str], *, timeout: float = 30.0) -> dict[str, Any]: - return get_graph_state_output(self.client, run_ids, timeout=timeout) + 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)) @@ -1181,8 +1209,7 @@ def search_pipeline_runs( limit: int = 10, page_token: str | None = None, ) -> dict[str, Any]: - return search_pipeline_runs( - client=self.client, + return PipelineRunSearch(client=self.client, logger=self.logger).search( name=name, created_by=created_by, annotations=annotations, @@ -1192,20 +1219,8 @@ def search_pipeline_runs( query=query, limit=limit, page_token=page_token, - logger=self.logger, ) - def annotations_list(self, run_id: str) -> dict[str, Any]: - return self.to_plain(self.client.pipeline_runs_annotations(run_id)) - - def annotations_set(self, run_id: str, key: str, value: Any) -> dict[str, Any]: - self.client.pipeline_runs_put_annotations(run_id, key, value=value) - return {"id": run_id, "key": key, "value": value} - - def annotations_delete(self, run_id: str, key: str) -> dict[str, Any]: - self.client.pipeline_runs_delete_annotations(run_id, key) - return {"id": run_id, "key": key, "deleted": True} - def export_run( self, run_id: str, @@ -1257,6 +1272,58 @@ def export_run( 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, @@ -1273,6 +1340,7 @@ def _poll_run_status( 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) @@ -1281,6 +1349,7 @@ def _poll_run_status( 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()) @@ -1296,6 +1365,7 @@ def _poll_run_status( 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( diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py index 1c1ff56..180e656 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -3,7 +3,7 @@ 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 -wrap these helpers with Shopify auth and legacy Typer entry points. +subclass ``PipelineRunSearch`` with Shopify-authenticated client creation. """ from __future__ import annotations @@ -13,10 +13,10 @@ import urllib.parse from dataclasses import dataclass from datetime import datetime, timezone -from collections.abc import Callable from typing import Any -from .logger import Logger, get_default_logger +from .handler import TangleCliHandler +from .logger import Logger _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _MAX_PIPELINE_NAME_WIDTH = 50 @@ -40,33 +40,24 @@ class PageChunk: next_ui_filter_url: str | None -class PipelineRunSearch: +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 while the legacy module-level functions remain - available. + or predicate builders. """ def __init__( self, client: Any = None, *, - client_factory: Callable[[], Any] | None = None, + client_factory: Any | None = None, logger: Logger | None = None, + **kwargs: Any, ) -> None: - self._client = client - self._client_factory = client_factory - self.logger = logger or get_default_logger() - - @property - def client(self) -> Any: - if self._client is None: - if self._client_factory is None: - raise ValueError("PipelineRunSearch requires a client or client_factory") - self._client = self._client_factory() - return self._client + 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]: @@ -141,7 +132,7 @@ def build_filter_query( ) 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.client, logger=self.logger) + return resolve_created_by(created_by=created_by, client=self._require_client(), logger=self.logger) def resolve_dates( self, @@ -173,7 +164,7 @@ def fetch_pages( end_date: str | None, ) -> tuple[list[dict[str, Any]], list[PageChunk], str | None]: return fetch_pipeline_run_search_pages( - client=self.client, + client=self._require_client(), filter_query_str=filter_query_str, limit=limit, page_token=page_token, @@ -232,7 +223,7 @@ def search( ) 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.client, "base_url", "").rstrip("/") + 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, @@ -719,32 +710,3 @@ def build_pipeline_run_search_result( "next_page_token": final_next_token, "ui_filter_url": first_ui_url, } - - -def search_pipeline_runs( - *, - client: Any, - 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, - logger: Logger | None = None, -) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`PipelineRunSearch.search`.""" - - return PipelineRunSearch(client=client, logger=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, - ) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index 4ddddcc..8c67c84 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -26,6 +26,7 @@ TokenOption, ) from .logger import Logger, logger_for_log_type +from .pipeline_run_annotations import AnnotationManager from .pipeline_run_manager import ( PipelineRunError, PipelineRunHooks, @@ -67,17 +68,20 @@ def _allow_all_hydration_for_args(args: ArgsContainer) -> bool: return bool(config.get("allow_all", False)) -def _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) -> PipelineRunManager: - client = LazyTangleApiClient( +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="pipeline-run commands", + command_name=command_name, ) + + +def _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) -> PipelineRunManager: return PipelineRunManager( - client=client, + 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), @@ -104,6 +108,22 @@ def _run_manager_action(config: str | None, cli_base_url: str | None, specs: dic 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, @@ -507,7 +527,7 @@ def pipeline_runs_annotations_list( "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.annotations_list(args.run_id)) + _run_annotation_action(config, base_url, specs, lambda manager, args: manager.list_annotations(args.run_id)) @annotations_app.command(name="set") @@ -530,11 +550,11 @@ def pipeline_runs_annotations_set( "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } - _run_manager_action( + _run_annotation_action( config, base_url, specs, - lambda manager, args: manager.annotations_set(args.run_id, args.key, args.value), + lambda manager, args: manager.set_annotation(args.run_id, args.key, args.value), ) @@ -556,9 +576,9 @@ def pipeline_runs_annotations_delete( "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } - _run_manager_action( + _run_annotation_action( config, base_url, specs, - lambda manager, args: manager.annotations_delete(args.run_id, args.key), + lambda manager, args: manager.delete_annotation(args.run_id, args.key), ) diff --git a/packages/tangle-cli/src/tangle_cli/published_components_cli.py b/packages/tangle-cli/src/tangle_cli/published_components_cli.py index 7d8df18..de4f70a 100644 --- a/packages/tangle-cli/src/tangle_cli/published_components_cli.py +++ b/packages/tangle-cli/src/tangle_cli/published_components_cli.py @@ -52,30 +52,6 @@ def _client_from_options( ) -def search_components(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import search_components as _search_components - - return _search_components(*args, **kwargs) - - -def inspect_by_digest(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import inspect_by_digest as _inspect_by_digest - - return _inspect_by_digest(*args, **kwargs) - - -def inspect_by_name(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import inspect_by_name as _inspect_by_name - - return _inspect_by_name(*args, **kwargs) - - -def get_standard_library(*args: Any, **kwargs: Any) -> Any: - from .component_inspector import get_standard_library as _get_standard_library - - return _get_standard_library(*args, **kwargs) - - app = App( name="published-components", help="Inspect and search published Tangle components from the registry.", @@ -125,9 +101,10 @@ def published_components_search( if require_available := getattr(client, "require_available", None): require_available() + from .component_inspector import ComponentInspector + print_json( - search_components( - client, + 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, @@ -189,16 +166,17 @@ def published_components_inspect( ) 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 = inspect_by_digest( - client, + result = inspector.inspect_by_digest( args.digest, full_spec=bool(args.full_spec), follow_deprecated=bool(args.follow_deprecated), ) else: - result = inspect_by_name( - client, + result = inspector.inspect_by_name( args.name or "", include_all_versions=bool(args.all_versions), include_deprecated=bool(args.include_deprecated), @@ -245,7 +223,9 @@ def published_components_library( if require_available := getattr(client, "require_available", None): require_available() - print_json(get_standard_library(client)) + from .component_inspector import ComponentInspector + + print_json(ComponentInspector(client=client, logger=logger, base_url=args.base_url).get_standard_library()) finally: finalize_logs() diff --git a/packages/tangle-cli/src/tangle_cli/secrets.py b/packages/tangle-cli/src/tangle_cli/secrets.py index 2074948..a0986dc 100644 --- a/packages/tangle-cli/src/tangle_cli/secrets.py +++ b/packages/tangle-cli/src/tangle_cli/secrets.py @@ -7,9 +7,10 @@ from __future__ import annotations import os -from collections.abc import Callable from typing import Any, Protocol +from .handler import TangleCliHandler + class SecretClient(Protocol): """Subset of the generated static client used by secret commands.""" @@ -39,7 +40,7 @@ class SecretValueError(ValueError): """Raised when secret value CLI/config inputs are invalid.""" -class SecretsManager: +class SecretsManager(TangleCliHandler): """Secret resource manager with injectable client construction. Downstream packages can inject a Shopify-authenticated client directly or @@ -51,18 +52,11 @@ def __init__( self, client: SecretClient | None = None, *, - client_factory: Callable[[], SecretClient] | None = None, + client_factory: Any | None = None, + logger: Any | None = None, + **kwargs: Any, ) -> None: - self._client = client - self._client_factory = client_factory - - @property - def client(self) -> SecretClient: - if self._client is None: - if self._client_factory is None: - raise ValueError("SecretsManager requires a client or client_factory") - self._client = self._client_factory() - return self._client + super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs) @staticmethod def resolve_secret_value(value: str | None, from_env: str | None) -> str: @@ -97,7 +91,7 @@ def secret_metadata(secret: Any) -> dict[str, Any]: def list(self) -> dict[str, Any]: """List secret metadata without exposing secret values.""" - response = self.client.secrets_list() + 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} @@ -114,7 +108,7 @@ def create( """Create a secret using generated static API operations.""" secret_value = self.resolve_secret_value(value, from_env) - secret = self.client.secrets_create( + secret = self._require_client().secrets_create( secret_name, secret_value, description=description, @@ -134,7 +128,7 @@ def update( """Update a secret using generated static API operations.""" secret_value = self.resolve_secret_value(value, from_env) - secret = self.client.secrets_update( + secret = self._require_client().secrets_update( secret_name, secret_value, description=description, @@ -145,92 +139,18 @@ def update( def delete(self, secret_name: str) -> dict[str, Any]: """Delete a secret using generated static API operations.""" - self.client.secrets_delete(secret_name) + self._require_client().secrets_delete(secret_name) return {"status": "success", "action": "deleted", "secret_name": secret_name} -# --------------------------------------------------------------------------- -# Compatibility helpers and thin module-level wrappers -# --------------------------------------------------------------------------- - - 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) -def _resolve_secret_value(value: str | None, from_env: str | None) -> str: - """Backward-compatible wrapper for :meth:`SecretsManager.resolve_secret_value`.""" - - return SecretsManager.resolve_secret_value(value, from_env) - - -def _secret_metadata(secret: Any) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`SecretsManager.secret_metadata`.""" - - return SecretsManager.secret_metadata(secret) - - -def list_secrets(client: SecretClient) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`SecretsManager.list`.""" - - return SecretsManager(client=client).list() - - -def create_secret( - client: SecretClient, - secret_name: str, - *, - value: str | None = None, - from_env: str | None = None, - description: str | None = None, - expires_at: str | None = None, -) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`SecretsManager.create`.""" - - return SecretsManager(client=client).create( - secret_name, - value=value, - from_env=from_env, - description=description, - expires_at=expires_at, - ) - - -def update_secret( - client: SecretClient, - secret_name: str, - *, - value: str | None = None, - from_env: str | None = None, - description: str | None = None, - expires_at: str | None = None, -) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`SecretsManager.update`.""" - - return SecretsManager(client=client).update( - secret_name, - value=value, - from_env=from_env, - description=description, - expires_at=expires_at, - ) - - -def delete_secret(client: SecretClient, secret_name: str) -> dict[str, Any]: - """Backward-compatible wrapper for :meth:`SecretsManager.delete`.""" - - return SecretsManager(client=client).delete(secret_name) - - __all__ = [ "SecretClient", "SecretValueError", "SecretsManager", - "_resolve_secret_value", - "create_secret", - "delete_secret", - "list_secrets", - "update_secret", ] diff --git a/packages/tangle-cli/src/tangle_cli/secrets_cli.py b/packages/tangle-cli/src/tangle_cli/secrets_cli.py index 4c03fb1..d6c2da1 100644 --- a/packages/tangle-cli/src/tangle_cli/secrets_cli.py +++ b/packages/tangle-cli/src/tangle_cli/secrets_cli.py @@ -24,7 +24,7 @@ TokenOption, ) from .logger import Logger, logger_for_log_type -from .secrets import SecretValueError +from .secrets import SecretsManager, SecretValueError ValueOption = Annotated[ str | None, @@ -143,9 +143,7 @@ def secrets_list( } def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: - from .secrets import list_secrets - - result = list_secrets(client) + result = SecretsManager(client=client).list() logger.info(f"Listed {result['count']} secret(s).") return result @@ -183,10 +181,7 @@ def secrets_create( ) def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: - from .secrets import create_secret - - result = create_secret( - client, + result = SecretsManager(client=client).create( args.secret_name, value=args.value, from_env=args.from_env, @@ -230,10 +225,7 @@ def secrets_update( ) def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: - from .secrets import update_secret - - result = update_secret( - client, + result = SecretsManager(client=client).update( args.secret_name, value=args.value, from_env=args.from_env, @@ -268,11 +260,9 @@ def secrets_delete( } def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]: - from .secrets import delete_secret - if not args.force: _confirm_delete(args.secret_name) - result = delete_secret(client, args.secret_name) + result = SecretsManager(client=client).delete(args.secret_name) logger.info(f"Deleted secret: {args.secret_name}") return result diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 736364d..baee411 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -297,33 +297,38 @@ def fake_client_from_options(**kwargs): "_client_from_options", fake_client_from_options, ) + from tangle_cli import component_inspector + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "search_components", - lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, + lambda self, **kwargs: {"client_ok": self._require_client() is fake_client, "search": kwargs}, ) + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "inspect_by_name", - lambda client, name, **kwargs: { - "client_ok": client is fake_client, + lambda self, name, **kwargs: { + "client_ok": self._require_client() is fake_client, "name": name, "inspect": kwargs, }, ) + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "inspect_by_digest", - lambda client, digest, **kwargs: { - "client_ok": client is fake_client, + lambda self, digest, **kwargs: { + "client_ok": self._require_client() is fake_client, "digest": digest, "inspect": kwargs, }, ) + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "get_standard_library", - lambda client: {"client_ok": client is fake_client, "folders": []}, + lambda self: {"client_ok": self._require_client() is fake_client, "folders": []}, ) run_app( @@ -419,10 +424,12 @@ def fake_client_from_options(**kwargs): return fake_client monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + from tangle_cli import component_inspector + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "search_components", - lambda client, **kwargs: {"client_ok": client is fake_client, "search": kwargs}, + lambda self, **kwargs: {"client_ok": self._require_client() is fake_client, "search": kwargs}, ) run_app( @@ -476,19 +483,22 @@ def fake_client_from_options(**kwargs): return fake_client monkeypatch.setattr(published_components_cli, "_client_from_options", fake_client_from_options) + from tangle_cli import component_inspector + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "inspect_by_digest", - lambda client, digest, **kwargs: { - "client_ok": client is fake_client, + lambda self, digest, **kwargs: { + "client_ok": self._require_client() is fake_client, "digest": digest, "inspect": kwargs, }, ) + monkeypatch.setattr( - published_components_cli, + component_inspector.ComponentInspector, "get_standard_library", - lambda client: {"client_ok": client is fake_client}, + lambda self: {"client_ok": self._require_client() is fake_client}, ) run_app( diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index d96d7db..6347feb 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -5,7 +5,7 @@ import pytest -from tangle_cli.artifacts import get_artifacts, _serialize_artifacts +from tangle_cli.artifacts import ArtifactManager def _artifact_response(artifact_id: str, uri: str) -> SimpleNamespace: @@ -75,10 +75,9 @@ def test_get_artifacts_resolves_direct_artifact_ids_without_run_tree() -> None: "artifact-2": _artifact_response("artifact-2", "gs://bucket/artifact-2"), } - artifacts = get_artifacts( + artifacts = ArtifactManager(client=client).get_artifacts( "run-1", {"artifact_ids": ["artifact-1", "artifact-2"]}, - client=client, ) assert list(artifacts) == ["artifact-1", "artifact-2"] @@ -95,7 +94,7 @@ def test_get_artifacts_resolves_execution_output_lookup() -> None: ) client.artifact_responses["artifact-model"] = _artifact_response("artifact-model", "gs://bucket/model") - artifacts = get_artifacts("run-1", {"executions": {"exec-1": ["model"]}}, client=client) + 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" @@ -117,7 +116,7 @@ def test_get_artifacts_resolves_task_query_from_run_details_tree() -> None: ) client.artifact_responses["artifact-model"] = _artifact_response("artifact-model", "gs://bucket/model") - artifacts = get_artifacts("run-1", {"tasks": {"Train": ["model"]}}, client=client) + artifacts = ArtifactManager(client=client).get_artifacts("run-1", {"tasks": {"Train": ["model"]}}) assert list(artifacts) == ["Train/model"] assert artifacts["Train/model"].uri == "gs://bucket/model" @@ -137,7 +136,7 @@ def test_get_artifacts_resolves_component_name_and_digest_queries() -> None: 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 = get_artifacts( + artifacts = ArtifactManager(client=client).get_artifacts( "run-1", { "components": [ @@ -145,7 +144,6 @@ def test_get_artifacts_resolves_component_name_and_digest_queries() -> None: {"digest": "sha256:score"}, ] }, - client=client, ) assert list(artifacts) == ["Embed/vectors", "Score/scores"] @@ -169,13 +167,12 @@ def test_get_artifacts_unions_outputs_from_multiple_matching_selectors() -> None 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 = get_artifacts( + artifacts = ArtifactManager(client=client).get_artifacts( "run-1", { "tasks": {"Train": ["model"]}, "components": [{"digest": "sha256:trainer", "outputs": ["metrics"]}], }, - client=client, ) assert list(artifacts) == ["Train/model", "Train/metrics"] @@ -196,7 +193,7 @@ def test_get_artifacts_resolves_nested_subgraph_task_paths() -> None: ) client.artifact_responses["artifact-inner"] = _artifact_response("artifact-inner", "gs://bucket/inner") - artifacts = get_artifacts("run-1", {"tasks": {"Subgraph/Inner": ["out"]}}, client=client) + 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" @@ -207,14 +204,14 @@ def test_get_artifacts_serializes_per_artifact_lookup_errors() -> None: client.artifact_responses["good"] = _artifact_response("good", "gs://bucket/good") client.artifact_errors["bad"] = RuntimeError("not found") - artifacts = get_artifacts("run-1", {"artifact_ids": ["good", "bad"]}, client=client) + 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 = _serialize_artifacts(artifacts) + serialized = ArtifactManager.serialize_artifacts(artifacts) assert {entry["key"]: entry for entry in serialized}["bad"] == { "id": "bad", "uri": "", @@ -230,4 +227,4 @@ def test_get_artifacts_requires_execution_details_for_task_queries() -> None: client.run_details["run-1"] = SimpleNamespace(execution=None) with pytest.raises(RuntimeError, match="No execution details"): - get_artifacts("run-1", {"tasks": {"Train": []}}, client=client) + ArtifactManager(client=client).get_artifacts("run-1", {"tasks": {"Train": []}}) diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py index 48f16ab..c7779d8 100644 --- a/tests/test_artifacts_cli.py +++ b/tests/test_artifacts_cli.py @@ -50,16 +50,16 @@ def fake_client_from_options(**kwargs: Any) -> object: client_calls.append(kwargs) return fake_client - def fake_get_artifacts(run_id: str, query: dict[str, Any], *, client: object) -> dict[str, object]: - get_calls.append({"run_id": run_id, "query": query, "client": 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, "get_artifacts", fake_get_artifacts) + monkeypatch.setattr(artifacts_module.ArtifactManager, "get_artifacts", fake_get_artifacts) monkeypatch.setattr( - artifacts_module, - "_serialize_artifacts", - lambda artifacts: [{"id": "artifact-config", "uri": "gs://bucket/config", "key": "artifact-config"}], + artifacts_module.ArtifactManager, + "serialize_artifacts", + staticmethod(lambda artifacts: [{"id": "artifact-config", "uri": "gs://bucket/config", "key": "artifact-config"}]), ) app = cli.build_app() @@ -107,8 +107,8 @@ def fake_client_from_options(**kwargs: Any) -> object: return object() monkeypatch.setattr(artifacts_cli, "LazyTangleApiClient", fake_client_from_options) - monkeypatch.setattr(artifacts_module, "get_artifacts", lambda *args, **kwargs: {}) - monkeypatch.setattr(artifacts_module, "_serialize_artifacts", lambda artifacts: []) + 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( diff --git a/tests/test_component_inspector.py b/tests/test_component_inspector.py index c7d493b..7d5c068 100644 --- a/tests/test_component_inspector.py +++ b/tests/test_component_inspector.py @@ -8,13 +8,7 @@ import pytest from tangle_cli import component_inspector -from tangle_cli.component_inspector import ( - get_standard_library, - inspect_by_digest, - inspect_by_name, - search_components, - transparency_check, -) +from tangle_cli.component_inspector import ComponentInspector from tangle_cli.models import ComponentInfo, ComponentSpec @@ -85,7 +79,7 @@ def test_standard_public_base_image_is_transparent(self): }, }) - transparent, reason = transparency_check(spec) + transparent, reason = ComponentInspector.transparency_check(spec) assert transparent is True assert "standard public base image" in reason @@ -98,7 +92,7 @@ def test_unknown_container_is_opaque(self): }, }) - transparent, reason = transparency_check(spec) + transparent, reason = ComponentInspector.transparency_check(spec) assert transparent is False assert "no inline source" in reason @@ -125,7 +119,7 @@ def request_path(self, path: str): client = LibraryClient() - library = get_standard_library(client) + library = ComponentInspector(client=client).get_standard_library() assert client.paths == ["/component_library.yaml"] assert library["folders"][0]["components"][0] == { @@ -155,7 +149,7 @@ def request_path(self, path: str): client = LibraryClient() - library = get_standard_library(client) + 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" @@ -192,8 +186,8 @@ def request_path(self, path: str): first_client = LibraryFallbackClient("private-a") second_client = LibraryFallbackClient(None) - first_result = inspect_by_name(first_client, "private-a") - second_result = inspect_by_name(second_client, "private-a") + 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" @@ -203,7 +197,7 @@ def request_path(self, path: str): class TestInspectComponents: def test_inspect_by_digest_merges_spec_and_publication_metadata(self): - result = inspect_by_digest(FakeClient(), "abc123") + result = ComponentInspector(client=FakeClient()).inspect_by_digest("abc123") assert result["status"] == "success" assert result["name"] == "demo" @@ -213,7 +207,7 @@ def test_inspect_by_digest_merges_spec_and_publication_metadata(self): assert "implementation" not in result["spec"] def test_inspect_by_digest_can_follow_deprecated_chain(self): - result = inspect_by_digest(FakeClient(), "old", follow_deprecated=True) + result = ComponentInspector(client=FakeClient()).inspect_by_digest("old", follow_deprecated=True) assert result["status"] == "success" assert result["digest"] == "abc123" @@ -225,7 +219,7 @@ def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: return [ComponentInfo(name="demo", digest="abc123")] return super().list_published_component_infos(**params) - result = inspect_by_digest(MissingPublishedVersionClient(), "abc123") + result = ComponentInspector(client=MissingPublishedVersionClient()).inspect_by_digest("abc123") assert result["status"] == "success" assert result["version"] == "1.2.3" @@ -237,13 +231,13 @@ def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: return [ComponentInfo(name="demo", digest="abc123")] return super().list_published_component_infos(**params) - result = inspect_by_name(MissingPublishedVersionClient(), "demo") + 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 = inspect_by_name(FakeClient(), "demo") + result = ComponentInspector(client=FakeClient()).inspect_by_name("demo") assert result["status"] == "success" assert result["name"] == "demo" @@ -251,7 +245,7 @@ def test_inspect_by_name_returns_matching_versions(self): assert result["versions"][0]["digest"] == "abc123" def test_search_components_returns_summary_rows(self): - result = search_components(FakeClient(), name="demo") + result = ComponentInspector(client=FakeClient()).search_components(name="demo") assert result == { "status": "success", @@ -271,6 +265,6 @@ class NullDescriptionClient(FakeClient): def list_published_component_infos(self, **params: Any) -> list[ComponentInfo]: return [ComponentInfo(name="demo", digest="abc123", description=None)] - result = search_components(NullDescriptionClient(), name="demo") + result = ComponentInspector(client=NullDescriptionClient()).search_components(name="demo") assert result["components"][0]["description"] == "" diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index d8acd87..b6b5fb0 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -495,7 +495,9 @@ def test_pipeline_runs_commands_call_generated_operations(monkeypatch, tmp_path: assert fake_client.list_calls[-1]["filter_query"] == "status:running" run_app(app, ["sdk", "pipeline-runs", "annotations", "list", "run-1"]) - assert json.loads(capsys.readouterr().out)["owner"] == "alice" + 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")] diff --git a/tests/test_resource_managers.py b/tests/test_resource_managers.py index da33b49..db9eb9e 100644 --- a/tests/test_resource_managers.py +++ b/tests/test_resource_managers.py @@ -8,11 +8,11 @@ import pytest -from tangle_cli.artifacts import ArtifactManager, get_artifacts, serialize_artifacts +from tangle_cli.artifacts import ArtifactManager from tangle_cli.models import ArtifactInfo -from tangle_cli.pipeline_run_details import PipelineRunDetails, get_graph_state_output, get_run_details_output -from tangle_cli.pipeline_run_search import PipelineRunSearch, search_pipeline_runs -from tangle_cli.secrets import SecretValueError, SecretsManager, create_secret, list_secrets +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: @@ -47,7 +47,7 @@ def factory() -> ArtifactClient: assert calls == ["created"] assert artifacts["artifact-1"].uri == "gs://bucket/artifact-1" - assert serialize_artifacts(artifacts) == [ + assert ArtifactManager.serialize_artifacts(artifacts) == [ { "id": "artifact-1", "uri": "gs://bucket/artifact-1", @@ -58,15 +58,6 @@ def factory() -> ArtifactClient: ] -def test_artifact_function_wrapper_delegates_to_manager() -> None: - client = ArtifactClient() - - artifacts = get_artifacts("run-1", {"executions": {"exec-1": ["model"]}}, client=client) - - assert artifacts["exec-1/model"].id == "artifact-model" - assert client.calls == ["execution:exec-1", "artifact:artifact-model"] - - class SecretClient: def __init__(self) -> None: self.created: list[tuple[str, str]] = [] @@ -120,9 +111,6 @@ def factory() -> SecretClient: "secret": {"secret_name": "NEW_SECRET", "description": "demo"}, } assert client.created == [("NEW_SECRET", "super-secret")] - assert list_secrets(client)["count"] == 1 - assert create_secret(client, "WRAPPED", value="wrapped")["action"] == "created" - with pytest.raises(SecretValueError): SecretsManager.resolve_secret_value("inline", "SECRET_VALUE") @@ -151,7 +139,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: } -def test_pipeline_run_search_class_and_function_wrapper() -> None: +def test_pipeline_run_search_class() -> None: client = SearchClient() manager = PipelineRunSearch(client=client) @@ -165,8 +153,6 @@ def test_pipeline_run_search_class_and_function_wrapper() -> None: "and": [{"value_contains": {"key": "system/pipeline_run.name", "value_substring": "pulse"}}] } - wrapped = search_pipeline_runs(client=client, query={"and": []}, limit=1) - assert wrapped["count"] == 1 def test_pipeline_run_search_lazy_factory() -> None: @@ -213,7 +199,7 @@ def executions_graph_execution_state(self, root_execution_id: str) -> SimpleName ) -def test_pipeline_run_details_class_and_function_wrappers() -> None: +def test_pipeline_run_details_class() -> None: client = DetailsClient() manager = PipelineRunDetails(client=client) @@ -226,11 +212,9 @@ def test_pipeline_run_details_class_and_function_wrappers() -> None: "include_execution_state": False, "execution_id": "exec-1", } - assert get_run_details_output(client, "run-2")["run"]["id"] == "run-2" graph = manager.get_graph_state_output(["run-1"]) assert graph["results"][0]["status_totals"] == {"SUCCEEDED": 1} - assert get_graph_state_output(client, ["exec-root"])["results"][0]["root_execution_id"] == "exec-root" def test_pipeline_run_details_lazy_factory() -> None: @@ -286,14 +270,13 @@ def guarded_import(name, *args, **kwargs): def test_resource_manager_import_surface() -> None: from tangle_cli.artifacts import ArtifactManager as ImportedArtifactManager - from tangle_cli.artifacts import serialize_artifacts as imported_serialize_artifacts 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 imported_serialize_artifacts({"a": ArtifactInfo(id="a", uri="u", key="a")})[0]["id"] == "a" + 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 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 index 162bbd6..4c172c9 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -25,33 +25,13 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: publish_component, publish_component_to_tangle, ) - from tangle_cli.artifacts import ( - ArtifactManager, - _collect_artifacts, - _collect_execution_artifacts, - _serialize_artifacts, - get_artifacts, - serialize_artifacts, - ) + from tangle_cli.artifacts import ArtifactManager from tangle_cli.module_bundler import ModuleBundler - from tangle_cli.secrets import ( - SecretsManager, - _resolve_secret_value, - create_secret, - delete_secret, - list_secrets, - update_secret, - ) + 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 ( - get_standard_library, - inspect_by_digest, - inspect_by_name, - search_components, - transparency_check, - ) + from tangle_cli.component_inspector import ComponentInspector from tangle_cli.pipeline_dehydrator import ( DehydrateChoice, Jinja2ExportResult, @@ -149,24 +129,16 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert callable(bump_version) assert ModuleBundler is not None assert ArtifactManager is not None - assert callable(_collect_artifacts) - assert callable(_collect_execution_artifacts) - assert callable(_serialize_artifacts) - assert callable(serialize_artifacts) - assert callable(get_artifacts) + assert callable(ArtifactManager.serialize_artifacts) assert SecretsManager is not None assert PipelineRunDetails is not None assert PipelineRunSearch is not None - assert callable(_resolve_secret_value) - assert callable(list_secrets) - assert callable(create_secret) - assert callable(update_secret) - assert callable(delete_secret) - assert callable(get_standard_library) - assert callable(inspect_by_digest) - assert callable(inspect_by_name) - assert callable(search_components) - assert callable(transparency_check) + 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 From f2f4898a61693fa69fe6279cbfac5b4f012ec4f1 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 17:54:43 -0700 Subject: [PATCH 087/111] fix(tangle-cli): recover submitted runs after timeout Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2 --- .../src/tangle_cli/pipeline_run_manager.py | 217 +++++++++++++++++- .../src/tangle_cli/pipeline_runner.py | 28 ++- tests/test_pipeline_runs_cli.py | 132 ++++++++++- 3 files changed, 363 insertions(+), 14 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index cc1595c..5720ae5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -14,6 +14,7 @@ import json import re import time +import uuid from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass, field @@ -35,6 +36,9 @@ _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): @@ -45,6 +49,10 @@ 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``. @@ -1497,6 +1505,132 @@ def _wait_result( 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]], @@ -1535,8 +1669,34 @@ def _run_body_factory( success = False error: Exception | None = None retry_requested = False - body = body_factory(attempt, previous_context, last_error) + 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") @@ -1557,14 +1717,50 @@ def _run_body_factory( try: with self.hooks.around_run(context): try: - response = self.submit_prepared_body( - body, - pipeline_path=pipeline_path, - attempt=attempt, - context=context, - ) - if attempt > 1: - self.hooks.after_retry_submit(context) + 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, + ) + else: + try: + response = self.submit_prepared_body( + body, + pipeline_path=pipeline_path, + attempt=attempt, + context=context, + ) + 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: + 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( @@ -1587,6 +1783,9 @@ def _run_body_factory( 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 diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index e0afa48..79ce7ae 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -552,11 +552,30 @@ def body_factory( def metadata_factory( attempt: int, - _previous_context: PipelineRunContext | None, + previous_context: PipelineRunContext | None, _error: Exception | None, ) -> dict[str, Any]: - preparation = preparations[attempt] + 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, @@ -592,5 +611,10 @@ def metadata_factory( 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/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index b6b5fb0..1997751 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1670,6 +1670,126 @@ def after_poll(self, poll, context): ] + +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 + + 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 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 + + 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 [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_run_pipeline_spec_uses_in_memory_spec_lifecycle() -> None: events = [] spec = { @@ -1703,7 +1823,8 @@ def after_submit_context(self, context): 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"]["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"), @@ -1999,7 +2120,8 @@ def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[ 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"]["team"] == "oss" + assert client.created[0]["annotations"]["tangle-cli/submission-id"] assert client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Run value" @@ -2050,6 +2172,10 @@ def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: 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) @@ -2072,7 +2198,7 @@ def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[ assert result["run_id"] == "run-2" assert [body["root_task"]["componentRef"]["spec"]["name"] for body in client.created] == [ "attempt-1", - "attempt-2", + "attempt-1", ] From 5829f614e5e1a9d8a62783f48894100ed2e9e7d0 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 18:13:37 -0700 Subject: [PATCH 088/111] fix(tangle-cli): defer submit failure hooks during recovery Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2 --- .../src/tangle_cli/pipeline_run_manager.py | 6 +++++- tests/test_pipeline_runs_cli.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 5720ae5..5eba7ca 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -1049,6 +1049,7 @@ def submit_prepared_body( 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"] @@ -1068,7 +1069,8 @@ def submit_prepared_body( try: response = self.to_plain(client.pipeline_runs_create(body=body)) except Exception as exc: - self.hooks.on_submit_error(exc, context=submit_context) + if notify_submit_error: + self.hooks.on_submit_error(exc, context=submit_context) raise if not isinstance(response, dict): response = {} @@ -1737,6 +1739,7 @@ def _run_body_factory( pipeline_path=pipeline_path, attempt=attempt, context=context, + notify_submit_error=False, ) except Exception as submit_exc: if context.run_id is not None: @@ -1751,6 +1754,7 @@ def _run_body_factory( 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, diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 1997751..db162d2 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1679,6 +1679,11 @@ 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 { @@ -1720,6 +1725,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: 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 @@ -1739,6 +1745,11 @@ 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 { @@ -1779,6 +1790,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: 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", From 6545567008e18daea033f1e9499aff2aec4c2507 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 18:36:00 -0700 Subject: [PATCH 089/111] fix(tangle-cli): run retry handoff after recovered submit Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2 --- .../src/tangle_cli/pipeline_run_manager.py | 2 + tests/test_pipeline_runs_cli.py | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 5eba7ca..db7f245 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -1732,6 +1732,8 @@ def _run_body_factory( attempt=attempt, context=context, ) + if attempt > 1: + self.hooks.after_retry_submit(context) else: try: response = self.submit_prepared_body( diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index db162d2..26b6530 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1802,6 +1802,69 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: 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 = { From e106f8b4d1cc7caac1681bc08e1170673d1d6428 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 09:50:34 -0700 Subject: [PATCH 090/111] fix: address OSS packaging review findings Assisted-By: devx/f5505a8e-b863-4860-8951-135c4c748c11 --- .github/CODEOWNERS | 2 +- .github/workflows/ci.yml | 32 +++ README.md | 8 +- .../tangle-cli/src/tangle_cli/artifacts.py | 2 +- .../src/tangle_cli/component_from_func.py | 5 +- .../src/tangle_cli/component_publisher.py | 14 +- packages/tangle-cli/src/tangle_cli/models.py | 2 +- .../src/tangle_cli/pipeline_hydrator.py | 2 +- .../src/tangle_cli/pipeline_run_details.py | 4 +- .../src/tangle_cli/pipeline_run_manager.py | 14 +- .../src/tangle_cli/pipeline_run_search.py | 4 +- .../src/tangle_cli/pipeline_runner.py | 6 +- .../tangle-cli/src/tangle_cli/pipelines.py | 2 +- packages/tangle-cli/src/tangle_cli/secrets.py | 6 +- pyproject.toml | 2 +- tests/test_api_cli.py | 16 +- tests/test_component_from_func.py | 10 +- tests/test_component_publisher.py | 8 +- tests/test_module_bundler.py | 8 +- tests/test_tangle_deploy_compat_imports.py | 14 +- uv.lock | 192 +++++++++--------- 21 files changed, 192 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cf4c311..ad9b281 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -# Require Alexey Volkov and Volv Grebennikov to review changes in this lab repo. +# 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/README.md b/README.md index bee07e2..7dd3b8c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # tangle-cli -[WIP] Private experimental/lab CLI for Tangle, the open-source ML pipeline orchestration platform. +CLI for Tangle, the open-source ML pipeline orchestration platform. -This lab repo is used to iterate on the next CLI shape before promoting changes to the public OSS package. The CLI is built with [Cyclopts](https://cyclopts.readthedocs.io/) and is intentionally split into two command families: +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. @@ -263,7 +263,7 @@ configs: name: Second component ``` -Batch `publish-all`, Slack notification flags, dbt generation, from-container generation, and backend-specific search-v2 workflows remain out of this OSS lab CLI slice. +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 @@ -360,7 +360,7 @@ At runtime, more `tangle api ...` commands become available in two ways: ## Generated model extension pattern -Generated models use a private generated base plus a stable public subclass. For example, codegen emits this shape for a model with a handwritten extension: +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): diff --git a/packages/tangle-cli/src/tangle_cli/artifacts.py b/packages/tangle-cli/src/tangle_cli/artifacts.py index 2a97a26..438bfab 100644 --- a/packages/tangle-cli/src/tangle_cli/artifacts.py +++ b/packages/tangle-cli/src/tangle_cli/artifacts.py @@ -85,7 +85,7 @@ 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 Shopify auth). The manager + ``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. """ diff --git a/packages/tangle-cli/src/tangle_cli/component_from_func.py b/packages/tangle-cli/src/tangle_cli/component_from_func.py index c476fb9..b24b52d 100644 --- a/packages/tangle-cli/src/tangle_cli/component_from_func.py +++ b/packages/tangle-cli/src/tangle_cli/component_from_func.py @@ -1616,8 +1616,7 @@ def build_component_dict( 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. See - # https://github.com/Shopify/discovery/issues/28703. Users can override by + # 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" @@ -1848,7 +1847,7 @@ def _path_annotation(path: Path) -> str: # 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 the real-Oasis run, defeating the + # 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 diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index 2a44d7d..54966b4 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -2,7 +2,7 @@ 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. Shopify-specific auth wrappers, Slack +checked-in/generated static API client. Provider-specific auth wrappers, notification plumbing, and a separate ``publish-all`` CLI are kept downstream. """ @@ -89,8 +89,8 @@ class ComponentPublishHook(Protocol): """Extension hook for downstream publishers. Downstream packages can implement one or more methods to observe publish - batches (for example, to send Slack summaries) without OSS importing or - knowing about those systems. Implementations that need richer metadata may + 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. """ @@ -143,10 +143,10 @@ def __init__( """Initialize the ComponentPublisher. Args mirror the generic ``tangle-deploy`` publisher shape, with - Shopify/Slack-specific 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. + 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__( diff --git a/packages/tangle-cli/src/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py index abe29bc..8ae96da 100644 --- a/packages/tangle-cli/src/tangle_cli/models.py +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -1,5 +1,5 @@ """ -API-contract dataclasses for the Tangle (Oasis) Cloud Pipelines API. +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, diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 072e82a..b05bffa 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -3,7 +3,7 @@ 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. Shopify/Oasis-only infrastructure integrations are omitted, and +compare. Provider-specific infrastructure integrations are omitted, and Docker/from-container materialization paths raise explicit unsupported errors. """ diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py index 6f3b20b..3591b23 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py @@ -1,8 +1,8 @@ """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 -Observe/GCP/Slack output through ``PipelineRunHooks.fetch_logs`` or wrappers. +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 diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index cc1595c..d8e6de9 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -2,9 +2,9 @@ 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; Shopify/GCP, -Slack, scheduler, mutex, run-as annotation defaults, and alternate log backends -are intentionally extension points rather than OSS behavior. +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 @@ -299,10 +299,10 @@ class PipelineWaitPoll: class PipelineRunHooks: """Overridable seams for downstream tangle-deploy behavior. - Subclasses can override these methods to add Shopify auth wrappers, gs:// - loading, JOB_CONFIG time input, run-as annotations, mutex/schedule behavior, - graceful shutdown, Slack notifications, Observe/GCP logs, or from-container - runtime defaults without forking the generic pipeline-run manager. + 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) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py index 180e656..c87a288 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -2,8 +2,8 @@ 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 Shopify-authenticated client creation. +formats results for CLI/MCP consumers. Downstreams such as tangle-deploy can +subclass ``PipelineRunSearch`` with provider-authenticated client creation. """ from __future__ import annotations diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index e0afa48..3dfb468 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -3,9 +3,9 @@ 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 (Shopify auth, gs:// I/O, Slack/Observe, mutexes, -schedulers, service-account annotations, and legacy result shapes) is exposed as -hooks rather than imported here. +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 diff --git a/packages/tangle-cli/src/tangle_cli/pipelines.py b/packages/tangle-cli/src/tangle_cli/pipelines.py index f1172cc..06ad8f7 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -93,7 +93,7 @@ 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 Shopify-only + the graph structure that the CLI commands consume rather than provider-specific deployment extensions or remote API fields. """ diff --git a/packages/tangle-cli/src/tangle_cli/secrets.py b/packages/tangle-cli/src/tangle_cli/secrets.py index a0986dc..bfb87d4 100644 --- a/packages/tangle-cli/src/tangle_cli/secrets.py +++ b/packages/tangle-cli/src/tangle_cli/secrets.py @@ -43,9 +43,9 @@ class SecretValueError(ValueError): class SecretsManager(TangleCliHandler): """Secret resource manager with injectable client construction. - Downstream packages can inject a Shopify-authenticated client directly or - provide a lazy ``client_factory``. Returned dictionaries intentionally omit - secret values and only include metadata. + 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__( diff --git a/pyproject.toml b/pyproject.toml index 42d8edc..75e19ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ authors = [ requires-python = ">=3.10" dependencies = [ "cloud-pipelines>=0.26.3.12", - "cloud-pipelines-backend", "cyclopts>=4.16.1", "docstring-parser>=0.16", "httpx>=0.28.1", @@ -60,6 +59,7 @@ dev = [ 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", diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index baee411..c38a98d 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -938,7 +938,7 @@ def fail_load_schema(): monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setenv("TANGLE_API_URL", "http://api.test") api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) monkeypatch.setattr(api_cli, "load_bundled_openapi_schema", fail_load_schema) @@ -1053,7 +1053,7 @@ def fake_get(url, **kwargs): assert "Unknown command" not in output -def _oasis_like_schema_with_published_component_extensions() -> dict: +def _tangle_like_schema_with_published_component_extensions() -> dict: schema = json.loads(json.dumps(SCHEMA)) schema["paths"]["/api/published_components/"]["get"] = { "tags": ["components"], @@ -1100,7 +1100,7 @@ def test_default_schema_source_merges_cached_backend_extensions( monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setenv("TANGLE_API_URL", "http://api.test") api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) monkeypatch.setattr( @@ -1124,7 +1124,7 @@ def test_default_schema_source_preserves_official_operation_on_cache_collision( monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setenv("TANGLE_API_URL", "http://api.test") api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) monkeypatch.setattr( @@ -1145,7 +1145,7 @@ def test_official_schema_source_hides_cached_extensions(monkeypatch, tmp_path, c monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setenv("TANGLE_API_URL", "http://api.test") api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) monkeypatch.setattr( @@ -1167,7 +1167,7 @@ def test_cache_schema_source_uses_raw_cached_schema(monkeypatch, tmp_path, capsy monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setenv("TANGLE_API_URL", "http://api.test") api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) monkeypatch.setattr( @@ -1363,7 +1363,7 @@ def test_config_can_select_cache_schema_at_build_time(monkeypatch, tmp_path, cap encoding="utf-8", ) api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) monkeypatch.setattr( @@ -1391,7 +1391,7 @@ def test_reset_cache_returns_auto_mode_to_official_only(monkeypatch, tmp_path, c monkeypatch.setenv("TANGLE_CLI_CACHE_DIR", str(tmp_path)) monkeypatch.setenv("TANGLE_API_URL", "http://api.test") api_cli.write_cached_schema( - _oasis_like_schema_with_published_component_extensions(), + _tangle_like_schema_with_published_component_extensions(), "http://api.test", ) diff --git a/tests/test_component_from_func.py b/tests/test_component_from_func.py index 46ddcb7..a4141fe 100644 --- a/tests/test_component_from_func.py +++ b/tests/test_component_from_func.py @@ -484,7 +484,7 @@ def test_basic_component(self): assert len(impl["args"]) > 0 def test_missing_docstring_falls_back_to_placeholder_description(self): - """Regression test for https://github.com/Shopify/discovery/issues/28703. + """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``, @@ -2236,7 +2236,7 @@ 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 / - # real-Oasis run). generate_component_yaml re-raises AuthoringStripError + # 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: @@ -2389,7 +2389,7 @@ def create_table( """Create a table. Args: - project: The GCP project. + project: The cloud project. table_name: The table name. Returns: @@ -2469,10 +2469,10 @@ def create_table( project: str, table_name: str, ) -> NamedTuple("Outputs", created_table=str): - \"\"\"Create a BigQuery table. + \"\"\"Create a data warehouse table. Args: - project: The GCP project. + project: The cloud project. table_name: The table name. Returns: diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py index 2b9ebc3..8a35160 100644 --- a/tests/test_component_publisher.py +++ b/tests/test_component_publisher.py @@ -371,8 +371,8 @@ def test_publish_components_passes_structured_context_to_context_aware_hooks(tmp hooks=[hook, kwargs_hook], git_remote_sha="sha", git_remote_branch="main", - git_remote_url="https://github.com/Shopify/discovery", - git_repo="Shopify/discovery", + git_remote_url="https://github.com/TangleML/example-pipelines", + git_repo="TangleML/example-pipelines", git_root=tmp_path, published_by="alice@example.com", ) @@ -383,8 +383,8 @@ def test_publish_components_passes_structured_context_to_context_aware_hooks(tmp 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/Shopify/discovery" - assert before_context.git_repo == "Shopify/discovery" + 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"}] diff --git a/tests/test_module_bundler.py b/tests/test_module_bundler.py index 95e7766..aeb0d98 100644 --- a/tests/test_module_bundler.py +++ b/tests/test_module_bundler.py @@ -1,9 +1,9 @@ """Tests for ``ModuleBundler`` ordering and dependency analysis. -Focused unit tests for the topological sort introduced to fix -https://github.com/Shopify/discovery/issues/30197 (alphabetical bundle -order can execute a dependent before its dependency, breaking -module-level references like ``FOO = bbb.bar()``). +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 diff --git a/tests/test_tangle_deploy_compat_imports.py b/tests/test_tangle_deploy_compat_imports.py index 4c172c9..69e4f8c 100644 --- a/tests/test_tangle_deploy_compat_imports.py +++ b/tests/test_tangle_deploy_compat_imports.py @@ -187,7 +187,7 @@ def test_tangle_deploy_required_import_surface_includes_static_client() -> None: assert utils_module is not None -def test_ci_var_globals_are_mutable_for_tangle_deploy_shopify_overrides() -> 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 @@ -196,14 +196,14 @@ def test_ci_var_globals_are_mutable_for_tangle_deploy_shopify_overrides() -> Non original_repo = u._CI_REPO_URL_VARS try: u._CI_GIT_ROOT_VARS = ("APPLICATION_ROOT", *u._CI_GIT_ROOT_VARS) - u._CI_SHA_VARS = ("SHOPIFY_BUILD_COMMIT", *u._CI_SHA_VARS) - u._CI_BRANCH_VARS = ("SHOPIFY_BUILD_BRANCH", *u._CI_BRANCH_VARS) - u._CI_REPO_URL_VARS = ("SHOPIFY_BUILD_REPO", *u._CI_REPO_URL_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] == "SHOPIFY_BUILD_COMMIT" - assert u._CI_BRANCH_VARS[0] == "SHOPIFY_BUILD_BRANCH" - assert u._CI_REPO_URL_VARS[0] == "SHOPIFY_BUILD_REPO" + 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 diff --git a/uv.lock b/uv.lock index ba31214..030af61 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-05T15:26:34.402328Z" +exclude-newer = "2026-06-19T16:44:11.311353Z" exclude-newer-span = "P7D" [manifest] @@ -20,7 +20,7 @@ members = [ [[package]] name = "alembic" version = "1.18.4" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, @@ -35,7 +35,7 @@ wheels = [ [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, @@ -44,7 +44,7 @@ wheels = [ [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, @@ -53,7 +53,7 @@ wheels = [ [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, @@ -67,7 +67,7 @@ wheels = [ [[package]] name = "asgiref" version = "3.11.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "attrs" version = "26.1.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } 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" }, @@ -88,7 +88,7 @@ wheels = [ [[package]] name = "bugsnag" version = "4.9.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webob" }, ] @@ -100,7 +100,7 @@ wheels = [ [[package]] name = "certifi" version = "2026.5.20" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, @@ -109,7 +109,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.7" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -214,7 +214,7 @@ wheels = [ [[package]] name = "click" version = "8.4.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -226,7 +226,7 @@ wheels = [ [[package]] name = "cloud-pipelines" version = "0.26.3.12" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, { name = "docstring-parser" }, @@ -258,7 +258,7 @@ dependencies = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -267,7 +267,7 @@ wheels = [ [[package]] name = "cyclopts" version = "4.16.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "docstring-parser" }, @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "detect-installer" version = "0.1.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -293,7 +293,7 @@ wheels = [ [[package]] name = "dnspython" version = "2.8.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, @@ -302,7 +302,7 @@ wheels = [ [[package]] name = "docker" version = "7.1.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, @@ -316,7 +316,7 @@ wheels = [ [[package]] name = "docstring-parser" version = "0.18.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/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" }, @@ -325,7 +325,7 @@ wheels = [ [[package]] name = "durationpy" version = "0.10" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, @@ -334,7 +334,7 @@ wheels = [ [[package]] name = "email-validator" version = "2.3.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython" }, { name = "idna" }, @@ -347,7 +347,7 @@ wheels = [ [[package]] name = "exceptiongroup" version = "1.3.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -359,7 +359,7 @@ wheels = [ [[package]] name = "fastapi" version = "0.136.3" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -388,7 +388,7 @@ standard = [ [[package]] name = "fastapi-cli" version = "0.0.24" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -409,7 +409,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" version = "0.19.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "detect-installer" }, { name = "fastar" }, @@ -429,7 +429,7 @@ wheels = [ [[package]] name = "fastar" version = "0.11.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -545,7 +545,7 @@ wheels = [ [[package]] name = "googleapis-common-protos" version = "1.75.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] @@ -557,7 +557,7 @@ wheels = [ [[package]] name = "greenlet" version = "3.5.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -627,7 +627,7 @@ wheels = [ [[package]] name = "grpcio" version = "1.81.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -688,7 +688,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -697,7 +697,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -710,7 +710,7 @@ wheels = [ [[package]] name = "httptools" version = "0.8.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -775,7 +775,7 @@ wheels = [ [[package]] name = "idna" version = "3.18" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, @@ -784,7 +784,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -793,7 +793,7 @@ wheels = [ [[package]] name = "isodate" version = "0.6.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] @@ -805,7 +805,7 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] @@ -817,7 +817,7 @@ wheels = [ [[package]] name = "kubernetes" version = "35.0.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "durationpy" }, @@ -837,7 +837,7 @@ wheels = [ [[package]] name = "legacy-cgi" version = "2.6.4" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -846,7 +846,7 @@ wheels = [ [[package]] name = "mako" version = "1.3.12" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "markdown-it-py" version = "4.2.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] @@ -870,7 +870,7 @@ wheels = [ [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, @@ -955,7 +955,7 @@ wheels = [ [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, @@ -964,7 +964,7 @@ wheels = [ [[package]] name = "oauthlib" version = "3.3.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, @@ -973,7 +973,7 @@ wheels = [ [[package]] name = "opentelemetry-api" version = "1.42.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -985,7 +985,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.42.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] @@ -997,7 +997,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.42.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, @@ -1015,7 +1015,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.42.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "opentelemetry-api" }, @@ -1033,7 +1033,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation" version = "0.63b1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1048,7 +1048,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation-asgi" version = "0.63b1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "opentelemetry-api" }, @@ -1064,7 +1064,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation-fastapi" version = "0.63b1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, @@ -1080,7 +1080,7 @@ wheels = [ [[package]] name = "opentelemetry-proto" version = "1.42.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] @@ -1092,7 +1092,7 @@ wheels = [ [[package]] name = "opentelemetry-sdk" version = "1.42.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1106,7 +1106,7 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions" version = "0.63b1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, @@ -1119,7 +1119,7 @@ wheels = [ [[package]] name = "opentelemetry-util-http" version = "0.63b1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/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" }, @@ -1128,7 +1128,7 @@ wheels = [ [[package]] name = "packaging" version = "26.2" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, @@ -1137,7 +1137,7 @@ wheels = [ [[package]] name = "platformdirs" version = "4.10.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -1146,7 +1146,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -1155,7 +1155,7 @@ wheels = [ [[package]] name = "protobuf" version = "6.33.6" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, @@ -1170,7 +1170,7 @@ wheels = [ [[package]] name = "pydantic" version = "2.13.4" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, @@ -1190,7 +1190,7 @@ email = [ [[package]] name = "pydantic-core" version = "2.46.4" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -1306,7 +1306,7 @@ wheels = [ [[package]] name = "pydantic-extra-types" version = "2.11.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, @@ -1319,7 +1319,7 @@ wheels = [ [[package]] name = "pydantic-settings" version = "2.14.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -1333,7 +1333,7 @@ wheels = [ [[package]] name = "pygments" version = "2.20.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, @@ -1342,7 +1342,7 @@ wheels = [ [[package]] name = "pytest" version = "9.0.3" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -1360,7 +1360,7 @@ wheels = [ [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] @@ -1372,7 +1372,7 @@ wheels = [ [[package]] name = "python-dotenv" version = "1.2.2" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, @@ -1381,7 +1381,7 @@ wheels = [ [[package]] name = "python-multipart" version = "0.0.30" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/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" }, @@ -1390,7 +1390,7 @@ wheels = [ [[package]] name = "pywin32" version = "311" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, @@ -1412,7 +1412,7 @@ wheels = [ [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, @@ -1476,7 +1476,7 @@ wheels = [ [[package]] name = "requests" version = "2.34.2" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -1491,7 +1491,7 @@ wheels = [ [[package]] name = "requests-oauthlib" version = "2.0.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "oauthlib" }, { name = "requests" }, @@ -1504,7 +1504,7 @@ wheels = [ [[package]] name = "rich" version = "15.0.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, @@ -1517,7 +1517,7 @@ wheels = [ [[package]] name = "rich-rst" version = "2.0.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, { name = "rich" }, @@ -1530,7 +1530,7 @@ wheels = [ [[package]] name = "rich-toolkit" version = "0.20.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, @@ -1544,7 +1544,7 @@ wheels = [ [[package]] name = "rignore" version = "0.7.6" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" }, @@ -1665,7 +1665,7 @@ wheels = [ [[package]] name = "sentry-sdk" version = "2.61.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, @@ -1678,7 +1678,7 @@ wheels = [ [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, @@ -1687,7 +1687,7 @@ wheels = [ [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, @@ -1696,7 +1696,7 @@ wheels = [ [[package]] name = "sqlalchemy" version = "2.0.50" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, @@ -1751,7 +1751,7 @@ wheels = [ [[package]] name = "starlette" version = "1.2.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -1764,7 +1764,7 @@ wheels = [ [[package]] name = "strip-hints" version = "0.1.13" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wheel" }, ] @@ -1794,7 +1794,6 @@ version = "0.0.1" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, - { name = "cloud-pipelines-backend" }, { name = "cyclopts" }, { name = "docstring-parser" }, { name = "httpx" }, @@ -1816,6 +1815,7 @@ codegen = [ { name = "alembic" }, { name = "bugsnag" }, { name = "cloud-pipelines" }, + { name = "cloud-pipelines-backend" }, { name = "fastapi", extra = ["standard"] }, { name = "kubernetes" }, { name = "opentelemetry-api" }, @@ -1833,7 +1833,6 @@ dev = [ [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 = ">=4.16.1" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -1852,6 +1851,7 @@ 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" }, @@ -1869,7 +1869,7 @@ dev = [ [[package]] name = "tomli" version = "2.4.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, @@ -1923,7 +1923,7 @@ wheels = [ [[package]] name = "typer" version = "0.26.7" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1938,7 +1938,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -1947,7 +1947,7 @@ wheels = [ [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -1959,7 +1959,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.7.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } 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/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, @@ -1968,7 +1968,7 @@ wheels = [ [[package]] name = "uvicorn" version = "0.49.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, @@ -1993,7 +1993,7 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, @@ -2037,7 +2037,7 @@ wheels = [ [[package]] name = "watchfiles" version = "1.2.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] @@ -2154,7 +2154,7 @@ wheels = [ [[package]] name = "webob" version = "1.8.10" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "legacy-cgi", marker = "python_full_version >= '3.13'" }, ] @@ -2166,7 +2166,7 @@ wheels = [ [[package]] name = "websocket-client" version = "1.9.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, @@ -2175,7 +2175,7 @@ wheels = [ [[package]] name = "websockets" version = "16.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, @@ -2243,7 +2243,7 @@ wheels = [ [[package]] name = "wheel" version = "0.47.0" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] @@ -2255,7 +2255,7 @@ wheels = [ [[package]] name = "wrapt" version = "2.2.1" -source = { registry = "https://pkgs.shopify.io/basic/data/python/simple/" } +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" }, From a03d366db745a316a1471c301b9c5698e6cf8246 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 10:00:13 -0700 Subject: [PATCH 091/111] chore: bump tangle-cli version to 0.1.0 Assisted-By: devx/f5505a8e-b863-4860-8951-135c4c748c11 --- packages/tangle-api/pyproject.toml | 2 +- packages/tangle-cli/src/tangle_cli/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_packaging.py | 3 ++- uv.lock | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index 8e4644c..4271d47 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -10,7 +10,7 @@ authors = [ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", - "tangle-cli==0.0.1", + "tangle-cli==0.1.0", ] [build-system] diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py index 49d78ee..fa83c8d 100644 --- a/packages/tangle-cli/src/tangle_cli/__init__.py +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -14,6 +14,6 @@ try: __version__ = metadata_version("tangle-cli") except PackageNotFoundError: - __version__ = "0.0.1" + __version__ = "0.1.0" __all__ = ["TangleDynamicDiscoveryClient", "__version__"] diff --git a/pyproject.toml b/pyproject.toml index 75e19ed..543a349 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 = [ diff --git a/tests/test_packaging.py b/tests/test_packaging.py index c0a23c4..5665204 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -105,7 +105,8 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: with zipfile.ZipFile(wheel) as archive: names = archive.namelist() - metadata = archive.read("tangle_cli-0.0.1.dist-info/METADATA").decode() + 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) diff --git a/uv.lock b/uv.lock index 030af61..6d71409 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T16:44:11.311353Z" +exclude-newer = "2026-06-19T16:59:19.613339Z" exclude-newer-span = "P7D" [manifest] @@ -1790,7 +1790,7 @@ requires-dist = [ [[package]] name = "tangle-cli" -version = "0.0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, From 9f52abd644b9bbc936af0052d024d0ced653b5d3 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 10:05:18 -0700 Subject: [PATCH 092/111] chore: bump tangle-api version to 0.1.0 Assisted-By: devx/f5505a8e-b863-4860-8951-135c4c748c11 --- packages/tangle-api/pyproject.toml | 2 +- pyproject.toml | 2 +- tests/test_packaging.py | 4 ++-- uv.lock | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index 4271d47..8fd377f 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-api" -version = "0.0.1" +version = "0.1.0" description = "Checked-in generated Tangle API models and operation proxies" readme = "../../README.md" authors = [ diff --git a/pyproject.toml b/pyproject.toml index 543a349..42daa28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ Repository = "https://github.com/TangleML/tangle-cli" Issues = "https://github.com/TangleML/tangle-cli/issues" [project.optional-dependencies] -native = ["tangle-api==0.0.1"] +native = ["tangle-api==0.1.0"] [project.scripts] tangle = "tangle_cli.cli:main" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5665204..5cb19a3 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -111,8 +111,8 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Requires-Dist: tangle-api==0.0.1" not in requires_dist - assert "Requires-Dist: tangle-api==0.0.1 ; extra == 'native'" in requires_dist + 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( diff --git a/uv.lock b/uv.lock index 6d71409..10bad91 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T16:59:19.613339Z" +exclude-newer = "2026-06-19T17:04:09.933107Z" exclude-newer-span = "P7D" [manifest] @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "tangle-api" -version = "0.0.1" +version = "0.1.0" source = { editable = "packages/tangle-api" } dependencies = [ { name = "pydantic" }, From 1d959f168999a488ae512a5a5a9de9b601e6ae05 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 17:55:13 -0700 Subject: [PATCH 093/111] fix(tangle-cli): recover submitted runs after timeout Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2 --- .../src/tangle_cli/pipeline_run_manager.py | 217 +++++++++++++++++- .../src/tangle_cli/pipeline_runner.py | 28 ++- tests/test_pipeline_runs_cli.py | 132 ++++++++++- 3 files changed, 363 insertions(+), 14 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index d8e6de9..bd692c3 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -14,6 +14,7 @@ import json import re import time +import uuid from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass, field @@ -35,6 +36,9 @@ _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): @@ -45,6 +49,10 @@ 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``. @@ -1497,6 +1505,132 @@ def _wait_result( 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]], @@ -1535,8 +1669,34 @@ def _run_body_factory( success = False error: Exception | None = None retry_requested = False - body = body_factory(attempt, previous_context, last_error) + 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") @@ -1557,14 +1717,50 @@ def _run_body_factory( try: with self.hooks.around_run(context): try: - response = self.submit_prepared_body( - body, - pipeline_path=pipeline_path, - attempt=attempt, - context=context, - ) - if attempt > 1: - self.hooks.after_retry_submit(context) + 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, + ) + else: + try: + response = self.submit_prepared_body( + body, + pipeline_path=pipeline_path, + attempt=attempt, + context=context, + ) + 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: + 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( @@ -1587,6 +1783,9 @@ def _run_body_factory( 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 diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 3dfb468..2b50d39 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -552,11 +552,30 @@ def body_factory( def metadata_factory( attempt: int, - _previous_context: PipelineRunContext | None, + previous_context: PipelineRunContext | None, _error: Exception | None, ) -> dict[str, Any]: - preparation = preparations[attempt] + 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, @@ -592,5 +611,10 @@ def metadata_factory( 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/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index b6b5fb0..1997751 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1670,6 +1670,126 @@ def after_poll(self, poll, context): ] + +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 + + 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 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 + + 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 [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_run_pipeline_spec_uses_in_memory_spec_lifecycle() -> None: events = [] spec = { @@ -1703,7 +1823,8 @@ def after_submit_context(self, context): 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"]["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"), @@ -1999,7 +2120,8 @@ def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[ 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"]["team"] == "oss" + assert client.created[0]["annotations"]["tangle-cli/submission-id"] assert client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Run value" @@ -2050,6 +2172,10 @@ def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: 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) @@ -2072,7 +2198,7 @@ def before_submit_pipeline_spec(self, pipeline_spec, **kwargs): # type: ignore[ assert result["run_id"] == "run-2" assert [body["root_task"]["componentRef"]["spec"]["name"] for body in client.created] == [ "attempt-1", - "attempt-2", + "attempt-1", ] From 75caade73ac60e66385b5d5848a5854b0ce86e84 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 18:13:55 -0700 Subject: [PATCH 094/111] fix(tangle-cli): defer submit failure hooks during recovery Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2 --- .../src/tangle_cli/pipeline_run_manager.py | 6 +++++- tests/test_pipeline_runs_cli.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index bd692c3..1bf3b4f 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -1049,6 +1049,7 @@ def submit_prepared_body( 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"] @@ -1068,7 +1069,8 @@ def submit_prepared_body( try: response = self.to_plain(client.pipeline_runs_create(body=body)) except Exception as exc: - self.hooks.on_submit_error(exc, context=submit_context) + if notify_submit_error: + self.hooks.on_submit_error(exc, context=submit_context) raise if not isinstance(response, dict): response = {} @@ -1737,6 +1739,7 @@ def _run_body_factory( pipeline_path=pipeline_path, attempt=attempt, context=context, + notify_submit_error=False, ) except Exception as submit_exc: if context.run_id is not None: @@ -1751,6 +1754,7 @@ def _run_body_factory( 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, diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 1997751..db162d2 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1679,6 +1679,11 @@ 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 { @@ -1720,6 +1725,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: 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 @@ -1739,6 +1745,11 @@ 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 { @@ -1779,6 +1790,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: 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", From 64f16ff182cb60931fbafeb12a48c5d6c67bf84f Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 26 Jun 2026 18:36:09 -0700 Subject: [PATCH 095/111] fix(tangle-cli): run retry handoff after recovered submit Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2 --- .../src/tangle_cli/pipeline_run_manager.py | 2 + tests/test_pipeline_runs_cli.py | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 1bf3b4f..4048882 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -1732,6 +1732,8 @@ def _run_body_factory( attempt=attempt, context=context, ) + if attempt > 1: + self.hooks.after_retry_submit(context) else: try: response = self.submit_prepared_body( diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index db162d2..26b6530 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -1802,6 +1802,69 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: 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 = { From 7095c8eb2a5801afd25a039413c9835341462984 Mon Sep 17 00:00:00 2001 From: Alexey Volkov Date: Mon, 29 Jun 2026 19:24:14 -0700 Subject: [PATCH 096/111] chore: Fixed the tests by configuring uv.toml: exclude-newer = "P7D" (#3) --- uv.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uv.toml b/uv.toml index 5bdd9aa..5843961 100644 --- a/uv.toml +++ b/uv.toml @@ -1,3 +1,5 @@ +exclude-newer = "P7D" + [[index]] url = "https://pypi.org/simple" default = true From 8724d06c0e280496e0bbc06a6409a8587fe25e88 Mon Sep 17 00:00:00 2001 From: Alexey Volkov Date: Tue, 30 Jun 2026 03:42:06 -0700 Subject: [PATCH 097/111] chore: Project - Set tangle-cli package version to 0.0.1a1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 42daa28..9912f7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.1.0" +version = "0.0.1a1" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index 10bad91..b2b4421 100644 --- a/uv.lock +++ b/uv.lock @@ -1790,7 +1790,7 @@ requires-dist = [ [[package]] name = "tangle-cli" -version = "0.1.0" +version = "0.0.1a1" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, From 21d2180772db504fd1617afff9ce7b56e229e571 Mon Sep 17 00:00:00 2001 From: Alexey Volkov Date: Tue, 30 Jun 2026 03:43:30 -0700 Subject: [PATCH 098/111] chore: GitHub - Added the "Publish release to PyPI" workflow --- .github/workflows/release.yaml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..fa580c8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,32 @@ +name: "Publish release to PyPI" + +on: + push: + tags: + # Publish on any tag starting with a `v`, e.g., v0.1.0 + - v* + +jobs: + run: + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - name: Install Python 3.13 + run: uv python install 3.13 + - name: Build + run: uv build + # Check that basic features work and we didn't miss to include crucial files + - name: Smoke test (wheel) + run: uv run --isolated --no-project --with dist/*.whl tangle version + - name: Smoke test (source distribution) + run: uv run --isolated --no-project --with dist/*.tar.gz tangle version + - name: Publish + run: uv publish From 2ac26710fdb2b38a4c418e24075876a194203763 Mon Sep 17 00:00:00 2001 From: Silin Raj Gupta <143409429+Silin144@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:55:18 -0400 Subject: [PATCH 099/111] feat: Add Tangent agent skills bundle (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the Tangent agent-skills bundle from the `tangle-cli-lab` staging repo — [tangle-cli-lab#78](https://github.com/TangleML/tangle-cli-lab/pull/78) — into this canonical CLI repo, as requested. ## What this adds `skills/tangent/` — an autonomous ML-experiment agent-skill bundle: the 8-step tuning loop (initialize → analyze → hypothesize → submit → monitor → evaluate → synthesize → decide) plus a roster of 7 subagents, driving the `tangle` CLI end to end. Entry index is `skills/tangent/SKILL.md`; the binding port contract is `skills/tangent/OSS-CONVENTIONS.md`. ## What changed vs. the lab PR - All `tangle-cli-lab` / "the lab repo" references retargeted to `tangle-cli` (this repo). - Commands run as `uv run tangle …` from a checkout until the package is published to PyPI. - Internal-infrastructure references stripped per the port contract (`OSS-CONVENTIONS.md` §9). ## Verification - 29 files, additions only under `skills/tangent/`. - Internal-token sweep: clean. - 43/43 relative cross-reference links resolve. Supersedes [tangle-cli-lab#78](https://github.com/TangleML/tangle-cli-lab/pull/78). --- skills/tangent/OSS-CONVENTIONS.md | 456 ++++++++++++++++++ skills/tangent/PORT-README.md | 83 ++++ skills/tangent/SKILL.md | 127 +++++ skills/tangent/agents/auth-wizard.md | 150 ++++++ skills/tangent/agents/builder.md | 254 ++++++++++ skills/tangent/agents/debugger.md | 141 ++++++ skills/tangent/agents/reporter.md | 104 ++++ skills/tangent/agents/researcher.md | 199 ++++++++ skills/tangent/agents/reviewer.md | 103 ++++ skills/tangent/agents/scenario-builder.md | 452 +++++++++++++++++ skills/tangent/references/data-sources.md | 334 +++++++++++++ skills/tangent/references/event-log.md | 110 +++++ .../example-scenarios/01-mslr-ranking.md | 165 +++++++ .../02-text-classification.md | 147 ++++++ .../references/example-scenarios/INDEX.md | 48 ++ .../tangent/references/iterating-on-runs.md | 56 +++ skills/tangent/references/knowledge-corpus.md | 128 +++++ skills/tangent/references/secrets.md | 347 +++++++++++++ skills/tangent/references/setup.md | 146 ++++++ .../tangent/references/step-0-initialize.md | 110 +++++ skills/tangent/references/step-1-analyze.md | 91 ++++ .../tangent/references/step-2-hypothesize.md | 42 ++ skills/tangent/references/step-3-submit.md | 174 +++++++ skills/tangent/references/step-4-monitor.md | 123 +++++ skills/tangent/references/step-5-evaluate.md | 53 ++ .../tangent/references/step-6-synthesize.md | 56 +++ skills/tangent/references/step-7-decide.md | 79 +++ skills/tangent/references/tangle-tools.md | 401 +++++++++++++++ .../tangent/references/uploading-artifacts.md | 229 +++++++++ 29 files changed, 4908 insertions(+) create mode 100644 skills/tangent/OSS-CONVENTIONS.md create mode 100644 skills/tangent/PORT-README.md create mode 100644 skills/tangent/SKILL.md create mode 100644 skills/tangent/agents/auth-wizard.md create mode 100644 skills/tangent/agents/builder.md create mode 100644 skills/tangent/agents/debugger.md create mode 100644 skills/tangent/agents/reporter.md create mode 100644 skills/tangent/agents/researcher.md create mode 100644 skills/tangent/agents/reviewer.md create mode 100644 skills/tangent/agents/scenario-builder.md create mode 100644 skills/tangent/references/data-sources.md create mode 100644 skills/tangent/references/event-log.md create mode 100644 skills/tangent/references/example-scenarios/01-mslr-ranking.md create mode 100644 skills/tangent/references/example-scenarios/02-text-classification.md create mode 100644 skills/tangent/references/example-scenarios/INDEX.md create mode 100644 skills/tangent/references/iterating-on-runs.md create mode 100644 skills/tangent/references/knowledge-corpus.md create mode 100644 skills/tangent/references/secrets.md create mode 100644 skills/tangent/references/setup.md create mode 100644 skills/tangent/references/step-0-initialize.md create mode 100644 skills/tangent/references/step-1-analyze.md create mode 100644 skills/tangent/references/step-2-hypothesize.md create mode 100644 skills/tangent/references/step-3-submit.md create mode 100644 skills/tangent/references/step-4-monitor.md create mode 100644 skills/tangent/references/step-5-evaluate.md create mode 100644 skills/tangent/references/step-6-synthesize.md create mode 100644 skills/tangent/references/step-7-decide.md create mode 100644 skills/tangent/references/tangle-tools.md create mode 100644 skills/tangent/references/uploading-artifacts.md diff --git a/skills/tangent/OSS-CONVENTIONS.md b/skills/tangent/OSS-CONVENTIONS.md new file mode 100644 index 0000000..349662f --- /dev/null +++ b/skills/tangent/OSS-CONVENTIONS.md @@ -0,0 +1,456 @@ +# OSS Conventions — Tangent Agent-Skills on `tangle-cli` + +> **This file is the single source of truth.** Every ported skill file (SKILL.md, +> `agents/*.md`, `references/*.md`) references this document for the CLI surface, +> invocation rule, auth, artifacts, learnings corpus, logs, annotations, and the +> resolved D1–D14 defaults. When a ported file needs a command, a flag, an env var, +> an auth recipe, or a corpus/artifact/log recipe, it cites *this* document rather +> than re-deriving it. If something here conflicts with an older internal habit +> (an internal dev-env shim, a cloud-bucket URI, an internal backend host), this document wins and the +> internal habit is a release blocker (see §9, Security mandate). + +Target CLI: **`github.com/TangleML/tangle-cli`** — binary `tangle`, package +`tangle-cli`, Apache-2.0. The CLI splits the surface into two families: +`tangle api …` (auto-generated OpenAPI wrappers) and `tangle sdk …` (hand-written +SDK / local / compound commands). The ported skills drive the **OSS core** (`sdk` +and `api`) and **never** any internal wrapper/hook layer. + +--- + +## 1. Invocation rule + +**Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo.** + +```bash +uv run tangle quickstart +uv run tangle --help +uv run tangle sdk --help +uv run tangle api --help +``` + +- **Never** prefix a command with an internal env-shim exec wrapper. That internal + dev-env tooling must not appear in any ported file. +- As of 2026-06-12 `tangle-cli` is **not yet a public PyPI package** (it is + consumed internally as a vendored git submodule). So install text must say + `uv run tangle …` **from a checkout**. Present the public install path only as a + future state: + + > *Once `tangle-cli` is promoted to the public OSS package, you will be able to + > `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, + > use `uv run tangle …` from a checkout of the repo.* + +- The `[native]` extra is what enables the static API-backed commands and the + handwritten `TangleApiClient` wrapper (see §3). In the `tangle-cli` workspace, `uv` + installs the workspace `tangle-api` package automatically for dev/tests. +- Help is standard cyclopts `--help`. **There is no `--help-extended` / `--help-full`.** +- Skills live **in-repo** (checked into the `tangle-cli` repo). There is **no internal + bundle-refresh step**; relative cross-references (`agents/*.md`, `references/*.md`) + resolve directly on disk. + +--- + +## 2. CLI mapping table (internal ` …` → OSS `tangle …`) + +This table is the canonical rename. The left column is the internal command a +porting agent will encounter in the source skill; the right column is the exact +OSS replacement. **Verbs/flags below were verified against the `tangle-cli` source** +(`cli.py`, `pipeline_runs_cli.py`, `pipelines_cli.py`, `components_cli.py`, +`published_components_cli.py`, `artifacts_cli.py`, `secrets_cli.py`). + +### Invocation / help / client + +| Internal | OSS | +|---|---| +| ` -- …` | `uv run tangle …` (or installed `tangle …` once promoted) | +| ` quickstart` | `tangle quickstart` (real; static onboarding text) | +| `--help-extended` / `--help-full` | `--help` | +| `from import TangleApiClient` | `from tangle_cli.client import TangleApiClient` (see §3) | + +### `pipeline-runs` (note: **plural** group `pipeline-runs`) + +| Internal | OSS | +|---|---| +| ` pipeline-run submit p.yaml -f c.yaml --hydrate --no-wait` | `tangle sdk pipeline-runs submit p.yaml [--arg K=V \| --args-json JSON] [--annotation K=V]` — **hydrate is the default; there is NO `--no-wait` (submit never waits); there is NO `-f config.yaml` (use `--arg`/`--args-json`, or `--config` for CLI-option defaults)** | +| `… submit … (submit-as-is, no version resolution)` | `tangle sdk pipeline-runs submit p.yaml --no-hydrate` | +| `… submit … --dry-run` (preview payload) | `tangle sdk pipeline-runs submit p.yaml --dry-run` (prints the submit body, creates no run) | +| ` pipeline-run details RUN_ID --state` | `tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| `… details … --implementations` | `… details … --include-implementations` | +| `… details … --include-annotations` | `… details … --include-annotations` (unchanged) | +| `… details … --execution-id EXEC_ID` | `tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID` | +| (light status) | `tangle sdk pipeline-runs status RUN_ID` (run + derived status summary) | +| (graph execution state) | `tangle sdk pipeline-runs graph-state EXECUTION_ID` | +| ` pipeline-run logs EXECUTION_ID` | `tangle sdk pipeline-runs logs EXECUTION_ID` (backend-native container logs — see §7) | +| ` pipeline-run search --name N` | `tangle sdk pipeline-runs search --name N` (also `--created-by`, `--annotation K=V`, `--start-date`, `--end-date`, `--limit N`, `--query JSON`, positional `QUERY`) | +| ` pipeline-run cancel RUN_ID` | `tangle sdk pipeline-runs cancel RUN_ID` | +| ` pipeline-run await … --max-wait` | `tangle sdk pipeline-runs wait RUN_ID --max-wait N --poll-interval N [--exit-on-first-failure]` (defaults: max-wait 600s, poll-interval 10s) | +| ` pipeline-run export RUN_ID out.yaml --dehydrate` | `tangle sdk pipeline-runs export RUN_ID --output out.yaml` (**drop `--dehydrate`; use `--output` — there is no `-o` alias for `export`; omit `--output` to print to stdout**) | +| ` pipeline-run annotations set RUN_ID K V` | `tangle sdk pipeline-runs annotations set RUN_ID K V` (also `annotations list RUN_ID`, `annotations delete RUN_ID K`) | + +### `pipelines` (local pipeline ops — **these live under `pipelines`, NOT `pipeline-runs`**) + +| Internal | OSS | +|---|---| +| ` pipeline-run validate p.yaml` | `tangle sdk pipelines validate p.yaml` | +| ` pipeline auto-layout p.yaml` | `tangle sdk pipelines layout p.yaml [--recursive] [-o out.yaml]` | +| ` pipeline hydrate t.yaml out.yaml` | `tangle sdk pipelines hydrate t.yaml -o out.yaml [--var K=V]` | +| ` pipeline view … --hydrate` | `tangle sdk pipelines diagram p.yaml` (Mermaid) / `tangle sdk pipelines layout p.yaml` — **no GUI viewer** | +| ` …-dehydrate-…` / any `dehydrate` verb/flag | **No OSS `dehydrate` command or `--dehydrate` flag.** Drop it (see §10, D1). `pipeline_dehydrator.py` exists only as unwired library code. | + +### `components` (local authoring) vs `published-components` (registry) + +| Internal | OSS | +|---|---| +| ` component generate from-python s.py` | `tangle sdk components generate from-python s.py [--function NAME] [--image REG/IMG:TAG] [--output OUT] [--name NAME] [--mode inline\|bundle] [--dependencies-from REQ] [--strip-code] [--resolve-root DIR]` | +| ` component generate from-docker …` / `--quick` / `--engine` | **No image build/push in the CLI.** Build/push the image yourself, then `generate from-python --image `. `set-container-image` is a `NotImplementedError` stub. (See §10, D7.) | +| ` component generate from-dbt …` | **Not in the CLI surface.** Drop; treat as out of scope. | +| ` component bump-version c.yaml` | `tangle sdk components bump-version c.yaml [--set-version V] [--update-timestamp]` (**bump-version is under `components`**) | +| ` component publish c.yaml` | `tangle sdk published-components publish c.yaml [--image …] [--name …] [--description …] [--annotations JSON] [--dry-run] [--published-by …]` (**publish is under `published-components`**) | +| ` publish-all …` | **No `publish-all`.** Pass a `--config` YAML/JSON list (or `_defaults` + `configs`) to the same `published-components publish`; it aggregates and exits nonzero on any error. | +| ` component inspect --name N --full-spec` | `tangle sdk published-components inspect --name N --full-spec` (or `inspect --digest DIGEST`; exactly one of NAME or `--digest`) | +| ` component search-v2 …` / `search … --semantic`/`--fuzzy`/`--regex`/`--schema` | `tangle sdk published-components search NAME [--digest D] [--published-by U] [--include-deprecated]` — **keyword/name/digest only; NO v2/semantic/fuzzy/regex/schema variants** (see §10, D11). May return empty on a fresh OSS install (feature off by default). | +| ` component deprecate DIGEST` | `tangle sdk published-components deprecate DIGEST [--superseded-by DIGEST]` | +| ` docs standard_components` | `tangle sdk published-components library` (curated standard library) | + +### `secrets`, `artifacts`, `docs` + +| Internal | OSS | +|---|---| +| ` secrets {list,create,update,delete}` | `tangle sdk secrets {list,create,update,delete}` — create/update take `--value`/`-v`, **prefer `--from-env`/`-e ENVVAR`** (avoids shell-history exposure), `--description`/`-d`, `--expires-at`; delete prompts unless `--force` | +| ` artifacts get RUN_ID -q …` | `tangle sdk artifacts get RUN_ID -q ''` (**metadata-only**, see §5). `-q`/`--query` is a JSON string with optional keys `tasks`, `components`, `executions`, `artifact_ids` | +| ` artifacts download RUN_ID -q … -o ./dir` | **No download-to-disk command.** Use the signed-URL fetch recipe in §5 / §10 D2. | +| ` docs {pipeline,component,debugging_runs,standard_components}` | **No `docs` command.** Point to `tangle sdk --help`, `tangle sdk published-components library`, and the public OSS docs at `github.com/TangleML/website/tree/master/docs` (see §10, D10). | + +### `auth` / SA setup / scheduler / run-as + +| Internal | OSS | +|---|---| +| ` auth …` / `` / Workload-Identity / Terraform | **No `auth` command group and no SA-setup command in the CLI.** Auth is plain env/flags (§4). The replacement auth-wizard is a thin token/header credential helper (§10, D5). | +| `` / scheduler | **No scheduler in OSS.** Reduce "do not schedule" guidance to a generic conceptual caution; do not name a `schedule` command (§10, D9). | +| `--run-as IDENTITY` | `--run-as` exists on `submit` but the **OSS default hooks do not support it** (downstream extension seam only). Drop run-as examples (§10, D9). | + +**Invocation rule for the whole table:** every left-column command, however it was +written internally, becomes the right-column form prefixed with `uv run` (e.g. +`uv run tangle sdk pipeline-runs submit …`). Auth flags from §4 attach to any +API-backed command. + +--- + +## 3. Programmatic client + +The stable public wrapper for Python tooling is: + +```python +from tangle_cli.client import TangleApiClient + +# Defaults to the local dev backend; pass base_url + auth for a remote backend. +client = TangleApiClient( + "http://localhost:8000", + token=None, # Bearer token shorthand + auth_header=None, # full Authorization value, e.g. "Bearer …" / "Basic …" + header=None, # one "Name: value" string or a list of them + headers=None, # mapping form, if you prefer a dict +) +run = client.pipeline_runs_get("run-id") +existing = client.find_existing_components( + ["component-name"], + published_by_substring="alice@example.com", +) +``` + +- Constructor signature: `TangleApiClient(base_url, *, token=, auth_header=, header=, headers=, …)`. + A bare `TangleApiClient()` only works against the default localhost backend. +- `TangleApiClient` lives in `tangle_cli.client` and inherits generated endpoint + methods from `tangle_api.generated.operations.GeneratedTangleApiOperations`. + **Importing it requires the native bindings** — install the `[native]` extra (or + provide a local `tangle_api.generated` package) before `from tangle_cli.client import …`. + The top-level `import tangle_cli` is intentionally native-free. +- **Prefer the CLI over Python snippets for status/polling.** Any internal snippet + that calls unverified methods like `get_pipeline_run`, `get_execution_graph_state`, + `set_verbose`, `.status_totals`, or `.root_execution_id` must be replaced. The + verified surface is `client.pipeline_runs_get(run_id)` and the + `find_existing_components(...)` semantic helper; for status/graph state the + **preferred** approach is the purpose-built CLI: + - `tangle sdk pipeline-runs status RUN_ID` (run + derived status summary) + - `tangle sdk pipeline-runs graph-state EXECUTION_ID` (graph execution state) + + Skills should call those CLI commands rather than hand-rolling a light-poll loop + in Python (see §10, D14). + +--- + +## 4. Auth & environment + +OSS auth is **explicit and layered**: explicit CLI option > `--config` file value > +environment default. **There is no `auth` command group.** These flags/env vars +replace the internal identity/session systems, cloud SA impersonation, the +internal backend's auth verification, and the internal package index **entirely**. + +| CLI option | Env var(s) | Purpose | +|---|---|---| +| `--base-url` | `TANGLE_API_URL` | API origin. Defaults to the local dev 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 (single object, a list, or `_defaults` + `configs`). | +| — | `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics (separate from progress logs). | + +- **Env-var rename:** internal `TANGLE_AUTH` (username:password) → OSS + `TANGLE_API_TOKEN` (Bearer) **or** `TANGLE_API_AUTH_HEADER` (full header for + Basic/other schemes). The deploy-time source-attribution env var → dropped (see §8). +- **Run links:** replace internal run-URL links (`https:///runs/`) with + `/runs/` **or** "inspect via `tangle sdk pipeline-runs details RUN_ID`". +- **No internal package index.** Resolve dependencies against public PyPI + (`uv sync` / `tangle-cli[native]`). +- Example for a protected backend: + + ```bash + uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --base-url https://api.example \ + --auth-header 'Bearer …' \ + -H 'X-Gateway-Auth: …' \ + --log-type console + ``` + +- **Auth-wizard replacement (D5):** a thin wizard that (1) asks for `--base-url`, + (2) picks one credential mechanism (`--token` vs `--auth-header` vs `-H`), + (3) shows the flag / env / `--config` setup, (4) verifies with a cheap call + (`tangle sdk pipeline-runs search --limit 1`), and (5) interprets 401/403/429. + No GCP/Workload-Identity/Terraform modes. + +--- + +## 5. Artifacts + +`tangle sdk artifacts get RUN_ID -q ''` is **metadata-only**. It returns +records of the form `{id, uri, size, hash}` (and a `count`); the `uri` is +**backend-agnostic**. There is **no `artifacts download` / `-o` to disk** in the CLI. + +- **URI scheme is HuggingFace, not an internal cloud-bucket scheme.** Under the OSS `tangle` backend the + storage provider is `huggingface_repo_storage.py`, so artifact URIs look like: + + ``` + hf://{model|dataset|space}s//@/ + ``` + + e.g. `hf://datasets/acme/eval-artifacts@main/run-123/metrics.json`. **Any skill + text that parses, echoes, or hard-codes a specific storage scheme (e.g. a + cloud-bucket URI) must be made scheme-agnostic** — read the `uri` field; do not assume a scheme. + +- **To fetch bytes** (the standard recipe — cite this from reporter/debugger/ + researcher/reviewer/scenario-builder/step-5): + + 1. Get metadata and read the `uri`: + ```bash + uv run tangle sdk artifacts get RUN_ID -q '{"artifact_ids":[""]}' + ``` + 2. Ask the backend for a signed URL: + ```bash + uv run tangle api artifacts signed-artifact-url --id + ``` + 3. Fetch with a generic client — `curl -L "" -o ./out`, or for + `hf://` URIs `huggingface_hub` (`hf_hub_download` / `snapshot_download`). + + Keep this recipe in one place (here); other files point at it rather than + repeating it. + +- **Metadata-only is sufficient for many checks.** Reviewers can verify + existence/size/hash from `artifacts get` alone. Only mandate byte-fetching where + per-example or metric-content analysis genuinely requires it (reporter), and there + point at the signed-URL recipe above (see §10, D2). + +--- + +## 6. Learnings corpus + +Replaces the internal cloud-storage learnings bucket. Two tiers: + +- **Default — local directory.** A configurable `LEARNINGS_DIR`, default + `$SCENARIO_DIR/learnings/`, overridable via env. Records are **run_id-keyed** + JSON. Replace the internal cloud-storage upload command with a plain copy/write: + + ```bash + mkdir -p "$LEARNINGS_DIR/" + cp learning.json "$LEARNINGS_DIR//learning-.json" + ``` + + Keep the run_id-keying scheme (`active_run_id` for research, `best_run_id` for + the learning) and keep the resilience event — but reword "upload" → "record" + (the event `learning_upload_failed` becomes `learning_record_failed`; see §8/§10 + D3). Genericize the word "bucket" → "corpus directory". + +- **Shared tier — HuggingFace dataset repo.** For a team-shared corpus (the shared + bucket's real purpose), the OSS-native equivalent matches the backend's own + storage provider: + + ``` + hf://datasets//@main//.json + ``` + + pushed with `huggingface_hub` (`HfApi().upload_file(...)`). Document local-dir as + the default and the HF dataset as the optional shared tier. **Never** reintroduce + an internal cloud-storage bucket. + +--- + +## 7. Logs + +Drop the **internal observability stack** (its hosts, datasets, `--source` +selectors, and auth env vars) entirely. The OSS log surface is split by what stores the data: + +- **Container logs (application stdout/stderr)** — backend-native, the **only** CLI-native + log surface: + + ```bash + uv run tangle sdk pipeline-runs logs EXECUTION_ID + ``` + + (Note: this is keyed by **EXECUTION_ID**, not run id.) + +- **Kubernetes/system events (OOM, eviction, scheduling, "pod not found")** — the + Tangle backend does **not** store these; they are **launcher-specific**. Frame as + "consult your launcher's runtime," with concrete per-launcher hints: + + | Launcher | System-event source | + |---|---| + | `kubernetes`, `google_kubernetes` | `kubectl get events`, `kubectl describe pod ` | + | `local_docker` | `docker logs `, `docker inspect ` | + | `skypilot` | the cluster console / `sky logs` | + | `huggingface` | the Space's logs in the HF UI | + +- This is a genuine **degradation** vs the internal observability path for *infra-failure* + diagnosis (no unified system-event search). The debugger skill must note the + weaker INFRA-failure signal and lean on container logs + launcher-native events. + +--- + +## 8. Annotations + +- **Drop the internal dashboard-compatibility annotation marker** and the entire + source-attribution mechanism (the deploy-source env var / `--source` flag). + These were pure downstream attribution for the internal dashboards and are safe to + remove for OSS. +- **Keep the generic annotation mechanism.** Portable annotations stay: + `--annotation session=… --annotation round=… --annotation type=… --annotation label=…` + (and `annotations set RUN_ID K V` after the fact). +- **Optional provenance only.** If a skill wants to mark its runs, use a single + optional `--annotation source=tangle-cli`. Do **not** make it mandatory and do + **not** reintroduce the internal dashboard marker. + +--- + +## 9. Security mandate (strip-list — release blocker) + +Per the team's security mandate, internal references in an OSS repo are +**attack vectors** (supply-chain + reconnaissance), not cosmetic. Treat **any** +residual internal reference in a ported file as a **release blocker**. + +No internal identifier may appear in **any** ported file — prose, code, +examples, comments, links, YAML, or annotations. The forbidden **categories** +(named here generically, so this public file does not itself carry the literal +strings it forbids): + +- Internal backend / gateway / observability **hostnames**, and any internal + corporate domain. +- Cloud-storage **bucket URIs and bucket names** tied to internal infra, and the + helper code or CLIs that read or upload them. +- Internal **observability**, **data-warehouse**, **experimentation**, and + **session / identity** systems — their hosts, datasets, source selectors, and + auth/session env vars. +- Internal or vendor **compute** project IDs and ML-platform endpoints. +- Internal **package-index** URLs. +- Internal **dev-environment** tooling and monorepo source / zone paths. +- Employee usernames / emails as data (PII), and service-account names. +- Internal component digests presented as resolvable. +- Internal dashboard-attribution annotations and source-attribution env vars. + +**Pre-release sweep.** The authoritative literal deny-list and the `grep` +patterns that enforce it **must live in private validation tooling, not in this +public tree** — shipping the literal internal strings, even inside a deny-list, +would itself violate the mandate. Before declaring any file done, run that +private sweep over the ported tree; then verify two non-sensitive structural +invariants directly here: every relative `../references/*.md` / `agents/*.md` +link still resolves, and no dropped subagent (the uploader) is still spawned. + +--- + +## 10. Resolved D1–D14 defaults + +These are **final** (from plan §4, several pre-resolved by team Slack in §0c). +Apply them identically across all files. + +- **D1 — Dehydrate.** Reframe the iteration loop around plain `export` (root spec + as-is) + hand-edit + `submit` (hydrate is the default). **Drop all `--dehydrate` + flags and the `grep -q ' spec:'` "dehydrate first" guards.** Do **not** add a new + dehydrate command. Accept hydrate-as-no-op on already-inline exports. + +- **D2 — Artifact bytes.** Document the `artifacts get` → `tangle api artifacts + signed-artifact-url` → fetch recipe **once** (here, §5). Keep reporter's per-example + analysis requirement but point it at that recipe. Reviewer relies on + **metadata-only** verification. + +- **D3 — Learnings corpus.** Canonical local path `LEARNINGS_DIR`, default + `$SCENARIO_DIR/learnings/` (env-overridable). Keep the corpus concept and the + resilience event, renamed `learning_record_failed`. Shared tier = HF dataset repo + (§6). + +- **D4 — Data-source components.** **Drop the dedicated data-source recipes** + (`Promote/Load/Find data source`, `Download from cloud storage`). Reframe artifact reuse as + "record `run_id` / artifact `uri` locally and re-reference." Gate + `data-sources.md` as a **conditional** pattern doc ("if your backend provides + these components"), never a prerequisite. + +- **D5 — Auth wizard.** **Replace** the GCP/Workload-Identity/Terraform wizard with a + minimal token/header wizard (see §4: ask base-url → pick `--token`/`--auth-header`/`-H` + → show flag/env/`--config` setup → verify with `tangle sdk pipeline-runs search + --limit 1` → interpret 401/403/429). Drop Modes 1/2 wholesale. + +- **D6 — Uploader agent.** **Drop the agent** (no OSS upload command; it relied on + the user's cloud-CLI auth + a hard-coded cloud-storage-download component). Preserve only the + portable kernels (file-vs-directory trailing-slash semantics, unique-filename + guidance, `componentRef` wiring shape) inside `uploading-artifacts.md`. Fix every + cross-reference that spawned the uploader to self-contained generic text. + +- **D7 — Containerized component build.** **Drop** + `containerized-component-iteration.md`. Document `generate from-python --image + ` (image built/pushed by the user with their own docker/podman) + as the only component-generation path in `builder.md`. `set-container-image` is a + stub; do not use it. + +- **D8 — Secrets.** **Drop GCP Secret Manager "Pattern B"** and the SA-impersonation + account-scoping section. **Keep Pattern A** (`dynamicData.secret`). Reduce + account-scoping to one generic paragraph: "secrets belong to the authenticating + identity; re-run with a different `--token` to create under another identity." + Prefer `secrets create --from-env ENVVAR` over `--value` in examples. + +- **D9 — Scheduler / run-as.** No scheduler command to name — reduce "do not + schedule promote" to a generic conceptual caution. **Drop `--run-as` examples** + (OSS default hooks reject it; it is a downstream extension seam only). + +- **D10 — `docs` command.** **Drop `docs` invocations.** Point to + `tangle sdk --help`, `tangle sdk published-components library`, in-repo + schema docs, and the public OSS docs at + `github.com/TangleML/website/tree/master/docs` (confirmed canonical OSS docs home). + +- **D11 — Component search.** Collapse to `tangle sdk published-components search + NAME` (keyword/name/digest only); **drop semantic/fuzzy/regex/schema variants.** + The PublishedComponents / remote-component-library feature is **off by default in + OSS**, so researcher/builder must treat component discovery as **optional** and + **tolerate empty results** on a fresh install — never a hard prerequisite of a + workflow step. + +- **D12 — Run arguments.** Standardize on `--arg K=V` and `--args-json ''` + (or `--args-json @file.json` where a file is wanted). **Rewrite every + `-f *.config.yaml` submit example** accordingly. `--config` carries CLI-option + defaults (base-url/auth/log-type), **not** pipeline run args. + +- **D13 — Example-scenarios bundle.** **Drop all 16 files** (`INDEX.md` + `01`–`15`) + — internal-domain case studies with near-zero portable CLI content and many + strip-list violations. **Author 1–2 fresh synthetic examples** on a public dataset + (e.g. MSLR/LETOR for ranking; a public image-text set for matching) plus a new + `INDEX.md`. Any synthetic scenario must name only public datasets/models and + treat scheduling/run-as/data-source promotion as extension-only. + +- **D14 — Step-4 light-poll client API.** **Replace the Python snippet** with the + CLI: `tangle sdk pipeline-runs status RUN_ID` and `tangle sdk pipeline-runs + graph-state EXECUTION_ID`. Do not ship a snippet that calls unverified client + methods. diff --git a/skills/tangent/PORT-README.md b/skills/tangent/PORT-README.md new file mode 100644 index 0000000..e755c8b --- /dev/null +++ b/skills/tangent/PORT-README.md @@ -0,0 +1,83 @@ +# Tangent Agent-Skills — Open-Source Port + +This directory is an **open-source port of the Tangent agent-skill bundle**: a set of +instructions that let an autonomous coding agent run ML pipeline-tuning experiments end to +end against the [`tangle-cli`](https://github.com/TangleML/tangle-cli) CLI (binary +`tangle`, the `tangle sdk` / `tangle api` surfaces). + +It is a faithful carbon copy of the original bundle with **every infrastructure-specific +reference removed and re-expressed against the public CLI**. The substitutions are not ad +hoc — they are governed by a single binding contract, [`OSS-CONVENTIONS.md`](./OSS-CONVENTIONS.md), +which every file in this tree inherits. + +## What this is for + +The bundle drives an 8-step autonomous loop (initialize → analyze → hypothesize → submit → +monitor → evaluate → synthesize → decide) plus a roster of specialized subagents. Point a +capable agent harness at `SKILL.md` as the entry index and it can: read a tuning scenario, +form a hypothesis, edit a pipeline config, submit a run, monitor it, pull artifacts and logs, +evaluate the result, and record a durable learning — looping until the scenario's goal is met. + +## Layout + +``` +SKILL.md ← entry index: roster, the 8-step loop, invocation rules +OSS-CONVENTIONS.md ← the binding port contract (CLI surface, auth, artifacts, + logs, learnings, annotations, resolved defaults). Read first. +PORT-README.md ← this file +agents/ ← 7 subagents the loop spawns + builder.md debugger.md reporter.md researcher.md + reviewer.md scenario-builder.md auth-wizard.md +references/ ← the loop steps + topic references + step-0-initialize.md … step-7-decide.md + tangle-tools.md setup.md secrets.md data-sources.md + event-log.md knowledge-corpus.md iterating-on-runs.md uploading-artifacts.md + example-scenarios/ ← worked scenarios on PUBLIC datasets + INDEX.md 01-*.md 02-*.md +``` + +## How to invoke + +All commands run as **`uv run tangle …` from a checkout of the `tangle-cli` repo**. `tangle-cli` is not yet +a published package, so the public `pip install 'tangle-cli[native]'` / bare `tangle …` +*invocation* form is presented as a future state; elsewhere `tangle` still appears as a bare +command noun in prose and in the §2 CLI map (the naming-surface exception). See +`OSS-CONVENTIONS.md` §1. + +## What changed from the internal bundle + +The internal original was wired to closed, internal-only infrastructure. Each of those couplings was +replaced with the project's open, backend-agnostic equivalent: + +| Internal coupling (removed) | Open-source replacement | +| ---------------------------------- | -------------------------------------------------------------------- | +| Internal CLI wrapper + env shim | `uv run tangle …` (the public CLI), no environment shim | +| Cloud-object-storage artifact URIs | Scheme-agnostic artifact `uri` (e.g. `hf://…`); fetch via signed URL | +| Hosted log-search backend | `tangle sdk pipeline-runs logs` + launcher-native system events | +| Hard-coded internal API endpoint | `--base-url` / `TANGLE_API_URL` | +| Internal auth brokers / SSO | `--token` / `--auth-header` / `-H` (+ `TANGLE_API_*` env vars) | +| Cloud bucket "learnings" corpus | Local `LEARNINGS_DIR`, optional shared HuggingFace-dataset tier | +| Dashboard run-attribution tags | Dropped (generic user annotations retained) | + +A few source files were **dropped rather than ported**, by design: + +- **`agents/uploader.md`** — its portable guidance was folded into + `references/uploading-artifacts.md`; no surviving file spawns it. +- **`references/containerized-component-iteration.md`** — built on a container-build path that + is a not-implemented stub in the OSS CLI. +- **The 15 numbered case studies under `example-scenarios/`** — these were internal/customer + scenarios. They are replaced with self-contained synthetic scenarios on **public datasets**. + +## Provenance & the security contract + +This port treats internal references as a **release blocker**, not cosmetic cleanup: a leaked +internal hostname, project id, bucket URI, or credential path in open-source files is an attack +surface. `OSS-CONVENTIONS.md` §9 holds the full strip-list; every ported file was audited +against it. If you extend this bundle, run that audit on your additions before publishing. + +## Intended destination + +This tree is standalone and self-referential (all cross-references are relative). It is meant +to be dropped into the `tangle-cli` repo as an agent-skill bundle — for example at +`tangle-cli/skills/tangent/` (or wherever that repo loads agent skills from) — so the +harness can load `SKILL.md` as the index. diff --git a/skills/tangent/SKILL.md b/skills/tangent/SKILL.md new file mode 100644 index 0000000..0f122cb --- /dev/null +++ b/skills/tangent/SKILL.md @@ -0,0 +1,127 @@ +--- +name: tangent +description: ML experiment toolkit and autonomous agent. Say "tangent" for help, "tangent " for a specific tool, or "tangent auto" for the full autonomous experiment loop. Requires the Tangle CLI (the `tangle-cli` checkout) and a reachable Tangle backend. +allowed-tools: [Bash, Read, Write, Glob, Grep, Agent, dispatch] +--- + +# Tangent + +Thin index. Every file linked here is the source of truth for its topic — read it +when you need it, don't paraphrase from this file. + +For the CLI surface, invocation rule, auth, artifacts, learnings corpus, logs, and +annotations, the single source of truth is +[`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md). When any skill file (this one included) +needs a command, a flag, or an env var, it cites that document rather than +re-deriving it. + +## First, every session + +Skills live **in-repo** (checked into the `tangle-cli` repo) — there is no fetch or +bundle-refresh step. Relative cross-references in this skill (`agents/*.md`, +`references/*.md`) resolve directly on disk. Read +[`references/setup.md`](references/setup.md) to set up the CLI and auth. + +Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo. (Once `tangle-cli` is promoted to the public OSS package, you will be able to +`pip install 'tangle-cli[native]'` and invoke `tangle …` directly; until then, use +`uv run tangle …` from a checkout.) See +[`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md) §1. + +## Commands + +- **`tangent`** — print the help block below. +- **`tangent `** — read `agents/.md` and follow it. +- **`tangent auto`** — run the autonomous loop. See [Auto Mode](#auto-mode). + +``` +Subagents: Agent file: + tangent debugger agents/debugger.md + tangent researcher agents/researcher.md + tangent reporter agents/reporter.md + tangent reviewer agents/reviewer.md + tangent builder agents/builder.md + tangent auth agents/auth-wizard.md + tangent new-scenario agents/scenario-builder.md + +Automation: + tangent auto — Run full autonomous 8-step experiment loop +``` + +## References (read on demand) + +| Topic | File | +|---|---| +| Setup / auth | [`references/setup.md`](references/setup.md) | +| Tangle CLI | [`references/tangle-tools.md`](references/tangle-tools.md) | +| Event log schema | [`references/event-log.md`](references/event-log.md) | +| Iterating on a run | [`references/iterating-on-runs.md`](references/iterating-on-runs.md) | +| Uploading artifacts → pipelines | [`references/uploading-artifacts.md`](references/uploading-artifacts.md) | +| Reusing data sources | [`references/data-sources.md`](references/data-sources.md) | +| Secrets & credentials (API keys, tokens) | [`references/secrets.md`](references/secrets.md) | +| Example scenarios | [`references/example-scenarios/INDEX.md`](references/example-scenarios/INDEX.md) | +| Knowledge corpus (learnings) | [`references/knowledge-corpus.md`](references/knowledge-corpus.md) | + +## Tools + +Always use the `tangle` CLI via Bash. Do **not** use any `tangle` MCP tools. +Run `uv run tangle quickstart` to discover commands. See +[`references/tangle-tools.md`](references/tangle-tools.md). + +Cancel a run: `uv run tangle sdk pipeline-runs cancel RUN_ID` + +Background execution: `dispatch`. Subagents: `agents/*.md`. + +## Scenarios + +A scenario is any repo with a Tangle pipeline. Contents: `scenario.yaml`, `MEMORY.md`, +`skills/`, `sessions/`, `logs/`, `pipeline.yaml`. No scenario? `tangent new-scenario`. +For inspiration: [`references/example-scenarios/INDEX.md`](references/example-scenarios/INDEX.md). + +--- + +## Auto Mode + +Autonomous MLE agent that iterates on an ML pipeline. Tunes parameters, selects +features, changes data, analyzes results, modifies pipelines. + +### Memory & run_id + +- **Long-term**: `MEMORY.md` — best config, lessons, session index. < 3000 tokens. +- **Short-term**: `sessions/YYYY-MM-DD.md` — daily log, append-only. +- **Active runs**: written to `MEMORY.md` on Step 3 submission, cleared on Step 5 completion. +- **`run_id`** is a first-class session concept. Every Tangle pipeline-run has one; + the session log tracks them in order, and learnings recorded to the corpus are + keyed by `/`. Always record the `run_id` returned by + `tangle sdk pipeline-runs submit`. See the learnings corpus in + [`references/knowledge-corpus.md`](references/knowledge-corpus.md) and + [`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md) §6. + +### Procedure + +Read each step file only when you reach that step — do not pre-read all step files. +Execute the step, verify the gate checklist, do not proceed until every gate passes. +**Each step gate ends with a "reload skills + context" checkbox — actually re-read +the step file and active agent files at that point, don't rely on stale memory.** + +| Step | Reference | +|------|-----------| +| 0 Initialize | [`references/step-0-initialize.md`](references/step-0-initialize.md) | +| — Load builder skills | [`agents/builder.md`](agents/builder.md) (after init, before the loop) | +| 1 Analyze | [`references/step-1-analyze.md`](references/step-1-analyze.md) | +| 2 Hypothesize | [`references/step-2-hypothesize.md`](references/step-2-hypothesize.md) | +| 3 Submit | [`references/step-3-submit.md`](references/step-3-submit.md) | +| 4 Monitor | [`references/step-4-monitor.md`](references/step-4-monitor.md) | +| 5 Evaluate | [`references/step-5-evaluate.md`](references/step-5-evaluate.md) | +| 6 Synthesize | [`references/step-6-synthesize.md`](references/step-6-synthesize.md) | +| 7 Decide | [`references/step-7-decide.md`](references/step-7-decide.md) | + +Loop: Step 7 → Step 1 if not converged. Read `references/tangle-tools.md` and +`references/event-log.md` at Step 0 only. + +### Extracting metrics + +```python +value = metrics +for key in path.split("."): + value = value[int(key) if key.isdigit() else key] +``` diff --git a/skills/tangent/agents/auth-wizard.md b/skills/tangent/agents/auth-wizard.md new file mode 100644 index 0000000..50bc6b6 --- /dev/null +++ b/skills/tangent/agents/auth-wizard.md @@ -0,0 +1,150 @@ +--- +name: auth-wizard +description: Interactive wizard for configuring Tangle API credentials (base URL + token/header) and verifying access +tools: read, write, bash +--- + +# Tangent: Auth Wizard + +Interactive wizard that guides users through pointing the CLI at a Tangle +backend and configuring credentials so that API-backed commands authenticate. +OSS auth is **explicit and layered** — there is no `auth` command group, no +service-account setup, and no cloud-identity mode. Auth is plain flags and +environment variables (see [OSS-CONVENTIONS.md §4](../OSS-CONVENTIONS.md)). + +## Tools + +**Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo.** +Help is standard `--help` (there is no `--help-extended` / `--help-full`). + +Resolution precedence: explicit CLI option > `--config` file value > environment +default. + +| CLI option | Env var(s) | Purpose | +|---|---|---| +| `--base-url` | `TANGLE_API_URL` | API origin. Defaults to the local dev 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 flags; env accepts JSON or newline-separated `Name: value`. | +| `--config` | — | YAML/JSON defaults (single object, a list, or `_defaults` + `configs`). | + +--- + +## Wizard Flow + +When invoked, walk the user through the steps in order. Ask one thing at a time +and explain each answer in plain language before moving on. + +``` +Tangle Auth Wizard + +Let's point the CLI at your backend and get a credential working. I'll ask for: + 1. the backend base URL + 2. which credential mechanism your backend expects + 3. how you'd like to pass it (flag, env var, or --config file) +Then I'll verify access with a cheap read-only call. +``` + +### Step 1 — Base URL + +Ask for the API origin the CLI should talk to. + +- Example: `https://api.example` or `http://localhost:8000` (the default local + dev backend). +- This becomes `--base-url ` on any command, or the `TANGLE_API_URL` + environment variable. If the user is working entirely against the local dev + backend, they can skip this — the default applies. + +### Step 2 — Pick a credential mechanism + +Ask how the backend expects to be authenticated, and pick exactly one: + +- **`--token`** (`TANGLE_API_TOKEN`) — use when the backend takes a plain + **bearer token**. The CLI sends `Authorization: Bearer `. +- **`--auth-header`** (`TANGLE_API_AUTH_HEADER`) — use when you need to supply + the **full `Authorization` value** yourself, e.g. `Basic dXNlcjpwYXNz` or a + bearer with a non-standard prefix. Pass the entire header value. +- **`-H` / `--header`** (`TANGLE_API_HEADERS`) — use for a **gateway / custom + header** scheme (e.g. `X-Gateway-Auth: …`), either instead of or in addition + to the above. Repeatable. + +If the user is unsure, ask what their backend's docs say to send, or have them +try `--token` first — it is the most common. + +### Step 3 — Show the three ways to pass it + +Show all three for the chosen mechanism so the user can pick what fits their +workflow. Using `--token` as the example (substitute `--auth-header` / `-H` as +chosen in Step 2): + +**a) Inline flag** — explicit, per-command, highest precedence: + +```bash +uv run tangle sdk pipeline-runs search --limit 1 \ + --base-url https://api.example \ + --token '' +``` + +**b) Environment variable** — set once per shell, applies to every command: + +```bash +export TANGLE_API_URL='https://api.example' +export TANGLE_API_TOKEN='' +uv run tangle sdk pipeline-runs search --limit 1 +``` + +For `--auth-header` set `TANGLE_API_AUTH_HEADER`; for `-H` set +`TANGLE_API_HEADERS` (JSON object or newline-separated `Name: value`). + +**c) `--config` file** — checked-in/reusable defaults (single object): + +```yaml +# tangle.config.yaml +base-url: https://api.example +token: "" +``` + +```bash +uv run tangle sdk pipeline-runs search --limit 1 --config tangle.config.yaml +``` + +**Advise against committing secrets.** Prefer the env var, or keep the +`--config` file out of version control if it carries a real token. + +### Step 4 — Verify access + +Run a cheap, read-only call to confirm the credential works: + +```bash +uv run tangle sdk pipeline-runs search --limit 1 +``` + +(attach the auth flags from Step 3 if not using env/`--config`). A clean exit — +even with **zero results** — means auth succeeded and the CLI reached the +backend. An empty list is fine here; this is a connectivity/credential check, +not a data check. + +### Step 5 — Interpret the result + +Read the failure mode and explain it plainly: + +| Symptom | Likely cause | Fix | +|---|---|---| +| **401 Unauthorized** | Missing or invalid credential | Recheck the token/header value; confirm it hasn't expired and matches the mechanism the backend expects (Step 2). | +| **403 Forbidden** | Authenticated, but not permitted | The identity is recognized but lacks access to this resource — confirm the credential is for the right backend/identity. | +| **429 Too Many Requests** | Rate-limited | Back off and retry after a short wait; reduce request frequency. | +| **Connection error / timeout / DNS** | Wrong or unreachable `--base-url` | Recheck the URL (scheme, host, port); confirm the backend is up and reachable from where you're running. | + +For redacted HTTP request/response diagnostics, set `TANGLE_VERBOSE=1` and +re-run the verify call — it shows the wire-level exchange (with secrets +redacted) so you can see exactly what was sent. + +--- + +## Key Principles + +- **Explain as you go.** After each step, say what the answer means in plain language. +- **One credential mechanism.** Pick `--token` *or* `--auth-header` *or* `-H` (with `-H` optionally layered for a gateway); don't mix bearer schemes. +- **Don't print live secrets back.** Echo placeholders, not the real token value. +- **Prefer env vars for secrets** over inline flags (shell history) and over committed `--config` files. +- **Verify before declaring success.** A clean `pipeline-runs search --limit 1` — even with no results — is the proof that auth works. diff --git a/skills/tangent/agents/builder.md b/skills/tangent/agents/builder.md new file mode 100644 index 0000000..9fe46a2 --- /dev/null +++ b/skills/tangent/agents/builder.md @@ -0,0 +1,254 @@ +--- +name: builder +description: Build, modify, and iterate on Tangle pipelines and components +tools: read, write, grep, glob, bash +--- + +# Tangent: Builder Agent + +Build new pipelines and components, iterate on existing ones, and prepare +components with local code changes for testing. + +## Tools + +**Run every `tangle` command via Bash as `uv run tangle …` from a checkout of the `tangle-cli` repo** (see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). Once `tangle-cli` is promoted to the public OSS package you will be able to +`pip install 'tangle-cli[native]'` and invoke `tangle …` directly; until then use +`uv run tangle …`. + +Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +command or group for detailed usage (there is no `--help-extended` / `--help-full`). +For schema and concept docs, use `uv run tangle sdk --help`, +`uv run tangle sdk published-components library`, and the public OSS docs at +`github.com/TangleML/website/tree/master/docs`. + +| What you need | Command | +|---|---| +| Export run as YAML | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Inspect a published component | `uv run tangle sdk published-components inspect --name "Name" --full-spec` | +| Search components (optional; may be empty) | `uv run tangle sdk published-components search "keyword"` | +| Curated standard library | `uv run tangle sdk published-components library` | +| Generate from Python | `uv run tangle sdk components generate from-python source.py [--image REG/IMG:TAG]` | +| Bump version | `uv run tangle sdk components bump-version component.yaml` | +| Hydrate refs | `uv run tangle sdk pipelines hydrate template.yaml -o output.yaml` | +| Validate pipeline | `uv run tangle sdk pipelines validate pipeline.yaml` | +| Auto-layout DAG | `uv run tangle sdk pipelines layout pipeline.yaml` | +| Submit pipeline | `uv run tangle sdk pipeline-runs submit pipeline.yaml [--arg K=V \| --args-json JSON]` | +| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Component as used | `uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | + +Component discovery (search/library) is **optional** and **off by default** on a +fresh OSS install — treat it as a best-effort lookup and tolerate empty results; +never block a workflow step on it. + +## Credentials & secrets — read before wiring any API key + +If a pipeline argument is or looks like a credential (API key, bearer/OAuth +token, HF token, storage/registry token, anything matching `*_KEY` / `*_TOKEN` +/ `*_SECRET` / `*_PASSWORD`, anything a component reads from `os.environ`), it +MUST be wired through `dynamicData.secret`, never inlined. + +**Hard rule:** do not paste a credential value into a pipeline YAML, an input +field, a `constantValue:`, a `cli_args:` entry, a run-arg passed via `--arg` / +`--args-json` / `--config`, or a component image — even if you can read the +plaintext from your environment or a secret store. Treat raw credential values +as poison. + +The correct flow is: `uv run tangle sdk secrets list` → if the secret doesn't exist, +ask the human to create it with `uv run tangle sdk secrets create NAME --from-env NAME` +(`--from-env`/`-e` reads from an env var so the value never lands in shell +history; the agent never touches the value) → reference it via +`dynamicData.secret: { name: "NAME" }` on the consuming argument. See +[`../references/secrets.md`](../references/secrets.md) for the full workflow, +detection heuristics, identity-scoping rules, and anti-patterns. When in doubt, +stop and ask the human — never inline a value to unblock yourself. + +## Pipeline YAML Structure + +Tangle pipelines are nested subgraphs. Inputs flow through the hierarchy via +`graphInput` wiring: top-level task output → subgraph input → nested subgraph +input → leaf task argument. Trace the wiring at each level before modifying. + +## Workflows + +### Ingesting a staged artifact + +When a pipeline needs to consume a locally-staged file or directory (dataset, +model checkpoint, config bundle, …), the artifact must first live somewhere the +backend can read, and then be wired into a task argument. There is no built-in +upload command in OSS, so: + +1. Put the artifact in storage your backend can reach and obtain its `uri`. + Under the OSS backend, artifact URIs are **scheme-agnostic** and typically + HuggingFace-shaped (e.g. `hf://datasets//@main/`), not a + single hard-coded cloud scheme. See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5 + for the metadata/`uri` model and the signed-URL fetch recipe. +2. Wire the `uri` into the consuming task argument (as a `constantValue:` string, + or — better — as a run-arg so it is not baked into the spec). + +[`../references/uploading-artifacts.md`](../references/uploading-artifacts.md) +covers the portable wiring kernels: file-vs-directory trailing-slash semantics, +unique-filename guidance, and the `componentRef` shape. Pass the `uri` as a +run-config parameter (`--arg uri=`) when you want to swap the input without +editing the spec. + +### Preserving a run's output for reuse + +When a pipeline produces an artifact worth keeping past the run's TTL — +a curated eval set, a fine-tuned checkpoint, a generated annotation set, +a frozen feature snapshot — record its identity so a later pipeline can +re-reference it: capture the producing `run_id` and the artifact `uri` +(`uv run tangle sdk artifacts get RUN_ID -q ''` returns `{id, uri, size, hash}` +records; see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5) and save them +in the scenario's `MEMORY.md` / session log. A downstream pipeline re-references +the artifact by feeding that `uri` into the consuming task argument (typically +as a run-arg). + +**⚠️ Sensitive data warning.** Anything you persist for reuse may outlive the run +and be readable by others on a shared backend. **Never persist PII, sensitive +data, contractually restricted datasets, embargoed model weights, or anything +sourced from `secrets`.** If unsure, ask the user — or escalate to the data +owner — before persisting. + +**⚠️ Conceptual caution on recurring runs.** Persisting a snapshot on every run +of a recurring job mints a fresh permanent artifact each time and quickly floods +storage. OSS has no scheduler command, but if you wire any external recurring +trigger around a persisting pipeline, get explicit sign-off and a cleanup plan +first. + +If your backend exposes dedicated data-source components (`Promote to data +source` / `Load data source` / `Find data source`), you can use them instead of +hand-tracking `uri`s — that is a backend-conditional pattern, not a prerequisite. +See [`../references/data-sources.md`](../references/data-sources.md) for those +recipes; they apply only if your backend provides those components. + +### Reusing a preserved artifact + +If the pipeline needs a curated artifact someone has already preserved (a prior +round's eval set, a baseline checkpoint, a teammate's annotation set), re-reference +it by its recorded `uri`: feed the `uri` into the consuming task argument, ideally +as a run-arg so the spec stays generic. + +If your backend provides a `Load data source` component, you can instead wire it +with the recorded identifier; it exposes the data (file or directory matching the +original shape) plus a provenance record. This is backend-conditional — see Recipe +D in [`../references/data-sources.md`](../references/data-sources.md) for the +guarded pattern. Do not assume those components exist. + +### Iterating on an existing run + +See [`../references/iterating-on-runs.md`](../references/iterating-on-runs.md) for +the full workflow. + +1. **Export**: `uv run tangle sdk pipeline-runs export RUN_ID --output /tmp/pipeline.yaml` + — exports the root spec as-is (there is no `--dehydrate`). Omit `--output` to print + to stdout. +2. **Inspect**: `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` + — identify task statuses. (`uv run tangle sdk pipeline-runs status RUN_ID` gives a + lighter run + derived status summary.) +3. **Modify**: Edit the exported YAML. To swap a component, replace its `digest:` + or `url: file://` reference with a new `url: file://` pointing to your + replacement. +4. **Validate**: `uv run tangle sdk pipelines validate /tmp/pipeline.yaml` +5. **Submit** (see Submission Rules in + [`../references/tangle-tools.md`](../references/tangle-tools.md)): + ```bash + uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + --args-json @/tmp/pipeline.args.json + ``` + `submit` hydrates by default (it resolves component versions), so there is no + "dehydrate first" guard and no `--no-wait` — `submit` returns as soon as the + run is created; poll with `uv run tangle sdk pipeline-runs wait RUN_ID`. Pass run + arguments with `--arg K=V` / `--args-json ''` (or `--args-json @file.json`); + `--config` carries CLI-option defaults (base-url/auth/log-type), not run args. + +### Building a component with local code changes + +There is no image build/push step in the CLI. Build and push the container image +yourself with your own tooling (docker/podman), then point the component at it. +`set-container-image` is a `NotImplementedError` stub — do not use it. + +1. **Find source code**: Inspect the published component to get source annotations: + ```bash + uv run tangle sdk published-components inspect --name "Component Name" --full-spec + ``` + Check the annotations the publisher attached (e.g. source path, repo, image, + docs references) to locate the source. + +2. **Build and push the image** with your own container tooling, producing a + tagged image in a registry the backend can pull (e.g. + `registry.example/img:tag`). + +3. **Generate the component YAML** from your Python entrypoint, pointing at the + image you pushed: + ```bash + uv run tangle sdk components generate from-python source.py --image registry.example/img:tag + ``` + +4. **Insert into pipeline**: In the exported YAML, change the task's `componentRef` + from `digest: ...` to `url: file://`. Submit normally + (hydrate is the default). + +### Generating a new component + +**Before generating, optionally search for existing components** that already do +what you need. Component discovery is off by default on a fresh OSS install, so +treat this as best-effort and tolerate empty results: +```bash +# Optional keyword/name/digest search (may return nothing on a fresh install): +uv run tangle sdk published-components search "" +# Curated standard library: +uv run tangle sdk published-components library +``` +There are no v2/semantic/fuzzy/regex/schema search variants in OSS. If discovery +returns nothing, just generate the component you need. + +**From Python**: +```bash +uv run tangle sdk components generate from-python my_module.py +``` +Generates component YAML from a Python function. Looks for a function matching the +filename by default; use `--function ` to pick a different one. Pass +`--image registry/img:tag` to bind a container image you built and pushed +yourself. `generate from-python` is the only generation path — there is no +`from-docker` or `from-dbt`. + +### Publishing components + +```bash +# Bump version first +uv run tangle sdk components bump-version component.yaml + +# Publish +uv run tangle sdk published-components publish component.yaml +``` +`bump-version` lives under `components`; `publish` lives under +`published-components`. To publish several at once, pass a `--config` YAML/JSON +list (or `_defaults` + `configs`) to the same `published-components publish` — it +aggregates and exits nonzero on any error. + +### Validating before submission + +Always validate before submitting. See Submission Rules in +[`../references/tangle-tools.md`](../references/tangle-tools.md) for the full +pre-submit checklist. +```bash +uv run tangle sdk pipelines validate pipeline.yaml +``` +Use `--verbose` only if validation fails and you need full error details. + +## Key Gotchas + +- **Image registry access**: The image you bind with `--image` must be pullable by + the backend. If you get pull errors for private base images, check that the + registry credentials available to the backend are valid. +- **Image tag verification**: After modifying pipeline YAML (especially with + `yaml.dump` which reorders keys), verify the image reference is correct before + submitting. +- **Run details vs inspect**: Use `pipeline-runs details` with `--execution-id` + and `--include-implementations` to see the component as it was actually used in + a run. `published-components inspect` shows the latest published version, which + may differ. +- **Hydrate on submit**: `submit` hydrates by default — it resolves component + references (`digest:`, `url: file://`, or `name:`) to full inline specs. Pass + `--no-hydrate` only when you intend to submit a spec exactly as written; + hydration is a no-op on an already-inline export. diff --git a/skills/tangent/agents/debugger.md b/skills/tangent/agents/debugger.md new file mode 100644 index 0000000..8fc6ffb --- /dev/null +++ b/skills/tangent/agents/debugger.md @@ -0,0 +1,141 @@ +--- +name: debugger +description: Diagnose failed pipeline runs +tools: read, write, grep, bash +--- + +# Tangent: Debugger Agent + +Diagnose a failed pipeline run. Find the root cause, write a failure snapshot, +return a one-line diagnosis. + +## Tools + +**Always use the `tangle` CLI via Bash.** Run every command as `uv run tangle …` +from a checkout of the `tangle-cli` repo (see `OSS-CONVENTIONS.md` §1). Once `tangle-cli` is promoted to the public OSS package you will be able to `pip install +'tangle-cli[native]'` and invoke `tangle …` directly; until then, use `uv run +tangle …` from a checkout. + +Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +command (or group, e.g. `uv run tangle sdk pipeline-runs --help`) for detailed usage. +There is no `--help-extended` / `--help-full` and no `docs` command — for +debugging guidance, lean on `--help`, `uv run tangle sdk published-components library`, +and the public OSS docs at +[github.com/TangleML/website/tree/master/docs](https://github.com/TangleML/website/tree/master/docs). + +| What you need | Command | +|---|---| +| Run state & derived status summary | `uv run tangle sdk pipeline-runs status RUN_ID` | +| Execution tree & task states | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Graph execution state (per execution) | `uv run tangle sdk pipeline-runs graph-state EXECUTION_ID` | +| Container logs (application stack traces, code errors) | `uv run tangle sdk pipeline-runs logs EXECUTION_ID` | +| System events (eviction reasons, OOM kills, scheduling failures) | Launcher-native — NOT a Tangle command (see §7 and "Fetching System Events" below) | +| Search for runs | `uv run tangle sdk pipeline-runs search --name ` | +| Component spec (per-task) | `uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | +| Artifact metadata (URIs, size, hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Export pipeline spec | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | + +Artifact retrieval is **metadata-only** (`artifacts get` returns `{id, uri, size, +hash}`); the `uri` is backend-agnostic — read the scheme, don't assume one. There +is no `artifacts download`. To fetch artifact bytes, follow the signed-URL recipe +in `OSS-CONVENTIONS.md` §5. + +## Debugging Workflow + +1. **Get failure details**: `uv run tangle sdk pipeline-runs status RUN_ID` for a quick + run + derived status summary, then `uv run tangle sdk pipeline-runs details RUN_ID + --include-execution-state` — shows the execution tree with per-task status. Get + execution IDs for failed tasks. For a single failed execution's graph state, + `uv run tangle sdk pipeline-runs graph-state EXECUTION_ID`. +2. **Inspect the failed task**: `uv run tangle sdk pipeline-runs details RUN_ID + --execution-id EXEC_ID --include-implementations` — drill into the specific + failed execution to see the component spec as actually used. +3. **Fetch logs and system events** (see "Fetching Container Logs" in + `references/tangle-tools.md`): `uv run tangle sdk pipeline-runs logs EXECUTION_ID` + for application logs (stack traces, code errors). Container logs are keyed by + **EXECUTION_ID**, not run id. For system events (eviction, OOM, scheduling, + `pods "task-…" not found` mysteries), the Tangle backend does **not** store + these — they are **launcher-specific**. Consult your launcher's runtime (see + "Fetching System Events" below). +4. **Check for auth errors**: If logs show permission denied, 401/403, or token / + credential errors, classify as `PERMISSION` and note in the resolution that the + auth wizard (`agents/auth-wizard.md`) should be used to diagnose and fix the + base-url / token / header credential setup. +5. **Check upstream artifacts**: If logs mention missing data/inputs, check upstream + task outputs via `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` — an + upstream task may have produced empty or wrong output. The result is + metadata-only; existence/size/hash is often enough to spot an empty or truncated + artifact. Only fetch bytes (signed-URL recipe, `OSS-CONVENTIONS.md` §5) when you + genuinely need to inspect content. +6. **Export the pipeline**: `uv run tangle sdk pipeline-runs export RUN_ID --output + /tmp/pipeline.yaml` to get the exact pipeline spec used. Adjacent run arguments + were supplied at submit time via `--arg K=V` / `--args-json` / `--config` (there + is no `-f config.yaml`). +7. **Fix and re-run** (see Submission Rules in `references/tangle-tools.md`): + Modify the exported YAML, then resubmit. Hydration is the default; there is no + `--dehydrate` step to run first and no `--no-wait` flag (submit never waits): + ```bash + uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + --arg = + ``` + Submit returns immediately; to block on the result, use + `uv run tangle sdk pipeline-runs wait RUN_ID --max-wait N`. After submission, you may + annotate the run with generic provenance: + ```bash + uv run tangle sdk pipeline-runs annotations set source tangle-cli + ``` + +## Fetching System Events + +The Tangle backend stores **container logs** (via `pipeline-runs logs +EXECUTION_ID`) but **not** Kubernetes/system events. For OOM kills, evictions, +scheduling failures, and "pod not found" mysteries, consult your launcher's own +runtime: + +| Launcher | System-event source | +|---|---| +| `kubernetes`, `google_kubernetes` | `kubectl get events`, `kubectl describe pod ` | +| `local_docker` | `docker logs `, `docker inspect ` | +| `skypilot` | the cluster console / `sky logs` | +| `huggingface` | the Space's logs in the HF UI | + +**INFRA-failure graceful degradation.** Because there is no unified system-event +search, INFRA diagnosis is a weaker signal in OSS than in environments with a +central observability backend. When the container logs alone don't explain a +failure (e.g. the process is killed with no stack trace, or a task vanishes), lean +on container logs first, then the launcher-native events above. If neither yields a +root cause, classify as `INFRA` with the symptom you observed and recommend +re-running and checking the launcher runtime — do not block on a system-event +search the OSS surface cannot perform. + +## Inputs + +- `run_id` — the failed run +- `failure_playbook` — scenario's failure playbook (YAML) +- `task_mapping` — task name → source file +- `snapshot_path` — where to write the snapshot + +## Output + +1. **Snapshot file** at ``: +```markdown +# Failure: +- **Execution ID**: +- **Failed Task**: +- **Failure Type**: +- **Timestamp**: + +## Error + + +## Container Logs (last 50 lines) + + +## Resolution +- **Action**: + +## Lesson Learned + +``` + +2. **Return message**: `: ` diff --git a/skills/tangent/agents/reporter.md b/skills/tangent/agents/reporter.md new file mode 100644 index 0000000..dde7ab8 --- /dev/null +++ b/skills/tangent/agents/reporter.md @@ -0,0 +1,104 @@ +--- +name: reporter +description: Generate ML experiment report +tools: read, write, grep, bash +--- + +# Tangent: Reporter Agent + +Generate an ML experiment report. Regenerate from scratch each round. + +## Tools + +**Always use the `tangle` CLI via Bash. Do NOT use any MCP tools.** +Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo (see +[OSS-CONVENTIONS.md §1](../OSS-CONVENTIONS.md)). + +Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +command for detailed usage. + +| What you need | Command | +|---|---| +| Artifact metadata (id, `uri`, size, hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Fetch artifact bytes | metadata-only; resolve a signed URL and fetch — see [OSS-CONVENTIONS.md §5](../OSS-CONVENTIONS.md) | +| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | + +## Inputs + +- `scenario_dir` — scenario directory path +- `run_id` — **best run of the round** being reported (the round's representative run_id) +- `baseline_run_id` — for baseline artifacts +- `report_path` — where to write. Caller should pass a per-run path + (e.g. `/logs/report-.md`) so multi-round runs do not + overwrite each other. +- Read: `logs/audit.yaml`, `logs/events.jsonl`, `MEMORY.md`, `sessions/.md`, + `scenario.yaml`, `research-brief.md` (if exists) + +## Output + +`` — 7-section report following the template below. The filename +must include `` so each round's report is preserved. + +**CRITICAL: The Analysis section MUST include per-example winning/losing cases.** +Fetch predictions from baseline and best run, compare per-example, show the +top movers. Artifact access is metadata-only via `artifacts get`; to read the +prediction bytes, resolve a signed URL and fetch them with a generic client +(the recipe in [OSS-CONVENTIONS.md §5](../OSS-CONVENTIONS.md)). Read the artifact +`uri` scheme-agnostically — do not assume a storage scheme. If predictions are +truly unavailable, state why — do not silently skip. + +```markdown +# + +**Date**: YYYY-MM-DD +**Status**: IN_PROGRESS | SUCCESS | MARGINAL | NO_IMPROVEMENT | REGRESSION + +## Abstract +<3-5 sentences: problem, approach, result as baseline delta, insight> + +## Background & Related Work +**Baseline**: [](/runs/) — = +**Round's best run**: [](/runs/) +**Goal**: by at least + +## Methodology + + +## Results +| Metric | Baseline | Best | Delta % | +|--------|----------|------|---------| +Per-round progression, segment breakdown, guard status. + +## Analysis + +### Top Winning Cases +Fetch predictions from best run and baseline. Join on key columns. +Show top 5-10 examples where the model improved most. + +| Example | Baseline Score | Best Score | Delta | Why | +|---------|---------------|------------|-------|-----| + +### Top Losing Cases +Show top 5-10 examples where the model regressed most. + +| Example | Baseline Score | Best Score | Delta | Why | +|---------|---------------|------------|-------|-----| + +### Key Findings + + +## Discussion & Proposals +What worked/didn't, convergence assessment, open directions, recommendations. + +## Appendix +Best config diff, key run links, artifact `uri`s, agent reference YAML. +``` + +## Output Checklist — verify before returning: +- [ ] `` filename contains the round's `` (no overwrite of prior rounds) +- [ ] All 7 sections present +- [ ] Results table has actual numbers (not placeholders) +- [ ] **Top Winning Cases table has real examples from fetched predictions** +- [ ] **Top Losing Cases table has real examples from fetched predictions** +- [ ] Discussion proposes concrete next directions +- [ ] Appendix has run links and config diff diff --git a/skills/tangent/agents/researcher.md b/skills/tangent/agents/researcher.md new file mode 100644 index 0000000..17e4c4a --- /dev/null +++ b/skills/tangent/agents/researcher.md @@ -0,0 +1,199 @@ +--- +name: researcher +description: Pre-experiment research to produce a structured brief +tools: read, write, grep, glob, bash +--- + +# Tangent: Researcher Agent + +Find **high-impact optimization directions** a naive hyperparameter sweep would +miss: new features, better loss functions, architectural changes, novel techniques. +Think like an MLE who reads papers and asks "what if we tried this?" + +## Tools + +**Always use the `tangle` CLI via Bash. Do NOT use any MCP tool layer.** +Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo. (Once `tangle-cli` is promoted to the public OSS package you will be able to `pip install +'tangle-cli[native]'` and invoke `tangle …` directly; until then use `uv run tangle +…` from a checkout.) See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1. + +Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +command for detailed usage. For broader docs, see `uv run tangle sdk +published-components library` and the public OSS docs at +`github.com/TangleML/website/tree/master/docs`. + +| What you need | Command | +|---|---| +| Export run as YAML | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Inspect component | `uv run tangle sdk published-components inspect --name "Name" --full-spec` | +| Search components (optional, may be empty) | `uv run tangle sdk published-components search "keyword"` | +| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Run status | `uv run tangle sdk pipeline-runs status RUN_ID` | +| Artifact metadata (URIs/size/hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Fetch artifact bytes | metadata-only `get` → signed-URL recipe ([`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5) | +| Recent commits | `git log --oneline --since="Nd" -- ` | +| PRs/Issues | `gh pr list`/`gh issue list --repo --search ""` | +| Web search | `WebSearch(query="")` | +| Data platform | a configured data source / public dataset — preview with `LIMIT 10`, analysis `LIMIT 100` max. Select only needed columns. Summarize findings to a file after large queries. | + +**Component search is optional in OSS.** The published-component library feature is +off by default on a fresh install, so any search step may return nothing. Treat +component discovery as a best-effort assist, never a hard prerequisite — tolerate +empty results and fall back to inspecting known runs/components directly. + +## Inputs + +- `scenario_name`, `scenario.research` section +- `baseline_run_id` — the original baseline (stable across rounds) +- `parent_run_id` (optional) — for round 2+ re-research, the prior round's + best run that motivated the new research pass. `None` for round 1. +- `code_paths`, `image_roots` (image name → local package root) +- `brief_path`, `priors_path` — where to write output. Step 1 records + `brief_path` to the learnings corpus keyed by `parent_run_id` (round 2+) or + `baseline_run_id` (round 1) — see + [`../references/knowledge-corpus.md`](../references/knowledge-corpus.md). + +## Research Order: Direction First, Details Second + +**Decide the BIGGER DIRECTION before getting into details.** The wrong direction at +high effort beats the right direction at low effort, every time. Existing repo state +(recent commits, open PRs, GH issues, code archaeology) is **FYI context** — it tells +you what's been tried, not what's worth trying next. Do not over-anchor on it. + +### Phase A — Big direction (do first, always) + +These tracks frame the *space* of high-impact moves. They run before any code-level work. + +1. **Architecture & Gap Analysis** — what's MISSING vs current best practice + for this problem class? Where does the pipeline diverge from what a + strong team in 2026 would build today? +2. **Baseline Data** — metrics, SHAP, weak segments. Where is the model + actually failing? Which segments / inputs / labels carry the loss? +3. **Literature & Best Practices** — search broadly, actually READ papers, + find techniques that target the gaps from (1) and the failure modes from (2). + Bias toward methods with strong empirical evidence on similar setups. +4. **Data Platform** — untapped tables in a configured data source / public + dataset, label sources, signals, negative-mining sources. Use `LIMIT 10` for + previews, `LIMIT 100` max for analysis. Select only needed columns and filter + on partition keys. Write a summary file after any large query — don't leave raw + results in context. Also check whether a previous experiment **recorded** a + curated eval set, frozen feature snapshot, or annotation dataset you could reuse + — the scenario's `MEMORY.md` and session logs are the first place to look for a + recorded `run_id` / artifact `uri`. Reusing a preserved artifact beats + re-deriving one: record the `run_id` / artifact `uri` locally and re-reference + it. If your backend exposes data-source components, see + [`../references/data-sources.md`](../references/data-sources.md) (conditional — + only if those components are present in your backend). + +After Phase A, write down 2-4 candidate **directions** (not parameters): each one +a hypothesis about *what kind of change* is most likely to move the metric. + +### Phase B — FYI context (do second, lighter weight) + +Use these to *confirm or invalidate* a direction from Phase A — not to generate one. + +5. **Shipping History (FYI)** — recent commits, merged PRs, breaking changes. + Tells you what's already been tried; helps avoid re-running known dead ends. +6. **GitHub Issues (FYI)** — bugs, feature requests, tech debt. Surfaces + constraints and known pain. Not a source of direction. +7. **Code archaeology (FYI)** — repo layout, comments, TODOs. Use only when + a Phase A direction needs grounding in actual implementation details. +8. **Team chat (FYI)** — skip if unavailable. + +### Then: rank + +Synthesize Phase A directions into the ranked **Recommended Experiment Directions** +section below. Phase B context goes into the brief as "what's already been tried" +and "known constraints" — never as the primary justification for a direction. + +## Code Discovery + +Pipeline.yaml maps tasks → images → Python modules. Image tags are git SHAs. +Use `image_roots` to resolve modules to local files. + +To find source code for a published component, inspect it: +```bash +uv run tangle sdk published-components inspect --name "Component Name" --full-spec +``` +The `annotations` section includes `component_yaml_path`, `git_relative_dir`, and +`git_remote_url`. Use these to locate the YAML and source code in the repo. +Also check `dockerfile_path` (locates the Dockerfile used to build the image) and +`documentation_path` (points to component-specific docs, if any). +Use `--follow-deprecated` if the component is deprecated to find its successor. + +## Pipeline YAML Structure + +Tangle pipelines are nested subgraphs. Inputs flow through the hierarchy via +`graphInput` wiring: top-level task output → subgraph input → nested subgraph +input → leaf task argument. Trace the wiring at each level before modifying. +For pipeline and component schema details, use `uv run tangle sdk pipelines --help`, +`uv run tangle sdk published-components --help`, `uv run tangle sdk +published-components library`, and the public OSS docs at +`github.com/TangleML/website/tree/master/docs`. + +## Output + +1. `` — structured brief (template below) +2. `` — one-line actionable priors + +**CRITICAL: The brief has ONE ranking — the Recommended Experiment Directions at +the bottom. Sections 1-6 present findings only (what you discovered). Do NOT +rank or prioritize inside those sections. All prioritization goes into the final +Recommended Directions section, which synthesizes ALL tracks into a single +ordered list. No duplication between sections and the final ranking.** + +Rank directions by **gap severity × expected impact**: +- **Tier 1**: Missing capabilities (highest ceiling) +- **Tier 2**: Methodology improvements (high confidence) +- **Tier 3**: Parameter tuning (only non-obvious values with evidence) + +The #1 direction MUST be directly actionable with exact implementation steps. + +```markdown +# Research Brief: +**Generated**: YYYY-MM-DD +**Baseline run_id**: +**Parent run_id** (round 2+): +**Active run_id for record**: + + + +## Phase A — Big direction (primary) + +### 1. Gap Analysis + + +### 2. Baseline Performance & Weak Spots + + +### 3. Literature & Best Practices + + +### 4. New Feature & Data Opportunities + + +## Phase B — FYI context (secondary, do not anchor on) + +### 5. Recent Changes (FYI) + + +### 6. Open Issues & Team Context (FYI) + + +### 7. Code Archaeology (FYI) + + +## Task → Source Mapping +| Task | Image | Module | Local File | + +## Recommended Experiment Directions (THE ranking — synthesizes Phase A) +This is the ONLY place directions are ranked. **Directions come from Phase A.** +Phase B may provide constraints or invalidate a direction, but is never the +primary justification. + +1. — Gap: . Impact: . Evidence: Phase A items X,Y. **Implementation**: . +2. ... +(Order = what to try first. #1 is Round 1.) + +## Priors for the Agent +``` diff --git a/skills/tangent/agents/reviewer.md b/skills/tangent/agents/reviewer.md new file mode 100644 index 0000000..f94506d --- /dev/null +++ b/skills/tangent/agents/reviewer.md @@ -0,0 +1,103 @@ +--- +name: reviewer +description: Review experiment correctness from ML and implementation perspectives +tools: read, write, grep, glob, bash +--- + +# Tangent: Reviewer Agent + +You are a senior MLE reviewing an experiment before it's finalized. Your job is +to catch mistakes that would invalidate results — both implementation bugs and +ML methodology issues. Be skeptical. Check the work. + +## Tools + +**Always use the `tangle` CLI via Bash.** Run every command as `uv run tangle …` +from a checkout of the `tangle-cli` repo. See [OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) +§1 for the invocation rule and §4 for auth flags. + +Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +command for detailed usage. + +| What you need | Command | +|---|---| +| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Drill into a task | `uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | +| Container logs | `uv run tangle sdk pipeline-runs logs EXECUTION_ID` | +| Artifact metadata (uri/size/hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Export pipeline spec | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Inspect component | `uv run tangle sdk published-components inspect --name "Name" --full-spec` | + +`artifacts get` is **metadata-only** — it returns `{id, uri, size, hash}` records; +there is no `artifacts download`. Metadata is sufficient for review verification. +If you genuinely need the bytes, use the signed-URL recipe in +[OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) §5. + +## Inputs + +- `scenario_dir` — scenario directory path +- `best_run_id` — the run being proposed as the result (also keys the review filename) +- `baseline_run_id` — baseline for comparison +- `report_path` — the per-round report file (e.g. + `/logs/report-.md`) to read +- Read: ``, `MEMORY.md`, `logs/audit.yaml`, `logs/events.jsonl`, + `scenario.yaml`, `sessions/.md` + +## Review Checklist + +### Implementation Correctness +- Are the config changes what was intended? (diff baseline config vs best config) +- Did the pipeline run the right code version? (check image references — use `--include-implementations` to see the component as actually used) +- Were eval sets identical across runs? (if not, comparisons are invalid) +- Were there silent failures? (tasks that succeeded but produced empty/wrong outputs) +- Are artifact paths correct? (metrics from the right run — verify via `artifacts get` `uri`) + +### ML Methodology +- Is the improvement real or noise? (effect size vs eval set size) +- Are guard metrics actually passing, or barely? (check margins) +- Did the experiment test what it claimed? (hypothesis vs actual changes) +- Are there confounds? (multiple changes in one run → can't attribute improvement) +- Per-segment: did any segment regress badly while aggregate improved? +- Is the best config robust or was it cherry-picked from noise? + +### Report Quality +- Does the report accurately reflect the data? (spot-check key numbers) +- Are the conclusions supported by evidence? +- Are failure modes and negative results documented? +- Are open directions realistic and actionable? + +## Output + +Write to `/logs/review-.md` (per-round file — do not +write to a generic `review.md` or you will overwrite prior rounds): + +```markdown +# Experiment Review + +## Verdict: APPROVE | CONCERNS | BLOCK + +## Implementation +- [ ] Config changes match intent +- [ ] Same eval set across runs +- [ ] No silent failures +- [ ] Artifacts verified + +## ML Methodology +- [ ] Improvement exceeds noise threshold +- [ ] Guard metrics pass with margin +- [ ] No confounded experiments +- [ ] No segment regressions hidden by aggregate + +## Report +- [ ] Numbers accurate +- [ ] Conclusions supported +- [ ] Negative results documented + +## Issues Found + + +## Recommendations + +``` + +Return one line: `: ` diff --git a/skills/tangent/agents/scenario-builder.md b/skills/tangent/agents/scenario-builder.md new file mode 100644 index 0000000..50ce8e0 --- /dev/null +++ b/skills/tangent/agents/scenario-builder.md @@ -0,0 +1,452 @@ +--- +name: scenario-builder +description: Interactive scenario builder for Tangent experiments. Interviews the user to generate scenario.yaml, MEMORY.md, and skill files for any Tangle pipeline. +tools: read, write, bash, grep, glob, agent +--- + +# Tangent: Scenario Builder Agent + +You create Tangent scenarios by interviewing the user. Your job is to ASK +QUESTIONS and WAIT FOR ANSWERS — not to generate files immediately. + +All commands run as `uv run tangle …` from a checkout of the `tangle-cli` repo (see +[`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). + +## CRITICAL RULES + +1. **Do NOT write any files until you have completed ALL 6 interview phases + and the user has confirmed each checkpoint.** If you find yourself writing + a file before Phase 6 is done, STOP — you are doing it wrong. + The only exception: you may export `pipeline.yaml` in Phase 1 (automated, + no user input needed) and write skill files incrementally after their + respective phase checkpoints. + +2. **Every phase has a Gate checklist at the bottom. Print the checklist with + checkmarks after each phase. Do NOT proceed to the next phase until every + gate item passes.** This is the same pattern used in all Tangent step files. + +## Procedure + +``` +Phase 0: Setup → ask where + run ID → STOP, wait for answer +Phase 1: Context → introspect run, ask about pipeline → STOP, checkpoint +Phase 2: Metrics → show metrics, ask about target/guards → STOP, checkpoint +Phase 3: Code & Data → trace code, ask about sources + reusable data sources → STOP, checkpoint +Phase 4: Pitfalls → ask about failures → STOP, checkpoint +Phase 5: Search Space → auto-research or manual, define experiments → STOP, checkpoint +Phase 6: Budget → ask about runs/timing → STOP, checkpoint +Generate → ONLY NOW write scenario.yaml + MEMORY.md +Validate → show summary, ask for final confirmation +``` + +Each STOP means: present your checkpoint summary, then ask the user to +confirm before proceeding. Do not batch multiple phases into one message. + +### Phase 3 — data-sources sub-questions + +During Phase 3, **if your backend provides data-source components**, ask +whether this scenario should *consume* or *produce* promoted data sources +(see [`../references/data-sources.md`](../references/data-sources.md) — a +conditional pattern doc, not a prerequisite): + +- Does the pipeline have a curated dataset (eval set, frozen features, + annotations) that other experiments would benefit from? If yes, plan for + promoting it — and remind the user that every backend user will have access + to the promoted data, so the sensitive-data checklist applies, before + recording the resulting identifier in `MEMORY.md`. +- Does the pipeline depend on an artifact a previous run / teammate already + produced? Capture that run's `run_id` and the artifact `uri` so the scenario + can re-reference the data from day one instead of re-deriving it. + +If your backend does **not** provide these components, skip this sub-section — +record the upstream `run_id` / artifact `uri` locally and re-reference it +directly. + +## "Auto-research" option + +For Phases 1-5, use `AskUserQuestion` with an **"Auto-research"** option +so the user can delegate research to you. Present phase questions as a single +AskUserQuestion with these options: + +- **"Auto-research"** — agent does the research (always first option) +- **"I'll answer"** — user answers the questions themselves + +Example for Phase 5: +``` +AskUserQuestion: + question: "How should I define the search space? I can research the pipeline + to figure out what parameters to tune, what's off-limits, and what's cheap + vs expensive — or you can walk me through it." + header: "Search space" + options: + - label: "Auto-research" + description: "I'll launch the researcher agent to analyze code, data, + literature, and recent PRs to identify high-impact experiment directions" + - label: "I'll answer" + description: "I'll tell you what to try, what's off-limits, and what's + cheap vs expensive" +``` + +**When the user picks "Auto-research":** + +1. **Launch the researcher agent** — read [`researcher.md`](researcher.md) + and spawn it as a subagent via the Agent tool. Give it the baseline run ID, + scenario directory, and the specific questions from this phase that need + answering. The researcher does deep investigation: code tracing, data + analysis, literature search, gap analysis — NOT shallow introspection. +2. **Use the researcher's findings to answer your own questions** — extract + concrete answers from the research brief. Do not give generic answers like + "parameter tuning" — the researcher produces specific, evidence-backed + recommendations. +3. **Present your findings as the checkpoint** — "Based on my research: [answers + to each question with evidence]. Correct?" The user still confirms. +4. **The gate still applies** — every question must have an answer (yours or the + user's) and the user must confirm before proceeding. + +**When the user picks "I'll answer":** ask the numbered questions from the +phase and wait for their responses. + +This is NOT a skip. It means the researcher agent does the work instead of the +user, but the checkpoint confirmation is still mandatory. + +--- + +## Phase 0: Setup + +Ask these three questions using AskUserQuestion. Do not proceed until answered. + +1. **Where should the scenario live?** Ask the user for the **absolute local + path** of the directory the scenario should be created under. Scenarios + conventionally live in a `scenarios//` directory inside the project + that owns the pipeline, but the user decides — there is no fixed location. + + **After the user gives a path**, resolve `~` and confirm the full absolute + path back to them: "I'll create the scenario at ``. Correct?" + +2. **Do you have a baseline Tangle run ID?** + +3. **Do you have pipeline source code?** A pipeline YAML in your repo that uses + component `name:` or `digest:` refs (rather than fully-inlined specs) is + preferred over exporting from a run, because `--hydrate` on submit (the + default) will always resolve the latest published component versions. If + yes, ask for its path. If no, we'll export from the baseline run with + `pipeline-runs export` (there is no `--dehydrate` flag — see + [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §10 D1). + +STOP. Wait for answers before doing anything else. + +### Gate — do NOT proceed to Phase 1 until all pass: +- [ ] Asked user where the scenario should live +- [ ] Asked user for baseline run ID +- [ ] Asked user for pipeline source path (or confirmed export-from-run) +- [ ] User answered all questions +- [ ] `SCENARIO_DIR` set to a confirmed **absolute path** +- [ ] User confirmed the absolute path is correct + +--- + +## Phase 1: Problem Context + +**If the user provided a pipeline source path**, copy it to `/pipeline.yaml` +and introspect it to understand the pipeline structure. + +**If no source path but run ID was provided**, export it: +```bash +uv run tangle sdk pipeline-runs details # extract task names, pipeline structure +uv run tangle sdk pipeline-runs export --output /pipeline.yaml +``` +`export` writes the root spec as-is (omit `--output` to print to stdout). There is no +`--dehydrate` flag — `--hydrate` on submit (the default) resolves the latest +published versions for any component refs in the exported spec. For an +already-inline export, hydrate is effectively a no-op. + +**Present** what you found: "This pipeline has N tasks: [list]. It appears +to do [description]." + +**Scan for credential-shaped arguments while introspecting.** If any task +has an argument named like `*_API_KEY` / `*_TOKEN` / `*_SECRET` / +`authorization` / `bearer` etc. that is currently set via `constantValue:` +or a literal in the run config (instead of `dynamicData.secret`), flag it +to the user as a leaked-credential risk and link them to +[`../references/secrets.md`](../references/secrets.md). Do not paste the +value into any file you write — even when porting an existing pipeline. + +**Ask via AskUserQuestion** — "Auto-research" (researcher investigates +pipeline history, PRs, team context) or "I'll answer": +1. Is my description right? What am I missing about what this pipeline does? +2. Why optimize now? What's the business motivation? +3. What's been tried before? Any prior experiments or lessons? + +**STOP. Checkpoint:** "I'll record: [summary]. Correct?" + +### Gate — do NOT proceed to Phase 2 until all pass: +- [ ] Pipeline introspected (if run ID provided) +- [ ] `pipeline.yaml` exported (if run ID provided) +- [ ] Asked user to confirm/correct pipeline description +- [ ] Asked about business motivation +- [ ] Asked about prior experiments +- [ ] User confirmed checkpoint summary + +--- + +## Phase 2: Metrics & Guardrails + +**Introspect:** Read metrics from the baseline run. Find eval tasks, +get their metric artifacts, read the JSON. Use `artifacts get` +metadata and, where the metric JSON itself is needed, the +signed-URL fetch recipe in [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5. + +**Present:** Show all available metrics with current values in a table. + +**Ask via AskUserQuestion** — "Auto-research" (researcher analyzes +baseline metrics, weak segments, proposes target/guards) or "I'll answer": +1. Which metric is the optimization target? What direction? What magnitude + of improvement is meaningful? +2. For each secondary metric: "How much regression is acceptable?" Set + explicit thresholds for guardrails. +3. Any segment-level concerns? + +**STOP. Checkpoint:** "Target: [metric] = [value], [direction]. Guards: +[list with thresholds]. Correct?" + +After confirmation, write a skill file documenting the metrics (name it +based on what fits — e.g., `metrics-guide.md`, `eval-metrics.md`, etc.). + +### Gate — do NOT proceed to Phase 3 until all pass: +- [ ] Showed metrics table with current values +- [ ] Asked which metric is the optimization target + direction + meaningful magnitude +- [ ] Asked about regression thresholds for each secondary metric +- [ ] Asked about segment-level concerns +- [ ] User confirmed checkpoint summary +- [ ] Metrics skill file written + +--- + +## Phase 3: Code & Data + +**Introspect:** Trace pipeline tasks to source code using +`uv run tangle sdk published-components inspect --name --full-spec` +(component discovery is **optional** and off by default in OSS — tolerate +empty results and fall back to reading the component refs in `pipeline.yaml` +directly). If the pipeline reads from a query-backed data source (a warehouse +table, a data catalog, a dataset repo), use whatever discovery tool your +backend exposes to inspect the schema. Preview with small `LIMIT` clauses, +cap analysis queries at ~100 rows, and select only the columns you need. +After large queries, summarize findings to a file — don't leave raw results +in conversation context. + +**Present:** "Key source files: [paths]. Data comes from: [sources]." + +**Ask via AskUserQuestion** — "Auto-research" (researcher traces code +paths, discovers data sources, explores tables/datasets) or "I'll answer": +1. Did I get the code paths right? Anything important I missed? +2. Where does training data come from? How is eval data constructed? +3. What data sources (tables, datasets, repos) should Tangent know about? + +**STOP. Checkpoint:** "Code and data context covers: [summary]. Correct?" + +After confirmation, write skill files for code/data reference. Name them +based on content — NOT from a fixed template. Examples from other scenarios: +`code-guide.md`, `data-inspection.md`, `feature-importance.md`. Create +whatever this pipeline actually needs. + +### Gate — do NOT proceed to Phase 4 until all pass: +- [ ] Showed key source files and data sources +- [ ] Asked user to confirm/correct code paths +- [ ] Asked about training data origin and eval data construction +- [ ] Asked about relevant data sources (tables, datasets, repos) +- [ ] User confirmed checkpoint summary +- [ ] Code/data skill files written + +--- + +## Phase 4: Pitfalls & Failures + +**Ask via AskUserQuestion** — "Auto-research" (researcher analyzes failure +logs, GitHub issues, and resource usage patterns) or "I'll answer": +1. What mistakes would a new ML engineer make on this pipeline? +2. What errors does the pipeline commonly throw? How do you fix them? +3. Resource limits — memory, GPU, expected runtime? + +**STOP. Checkpoint:** "Pitfalls: [list]. Failure patterns: [list]. Correct?" + +### Gate — do NOT proceed to Phase 5 until all pass: +- [ ] Asked about common mistakes a new ML engineer would make +- [ ] Asked about common pipeline errors and fixes +- [ ] Asked about resource limits (memory, GPU, runtime) +- [ ] User confirmed checkpoint summary + +--- + +## Phase 5: Search Space & Experiment Actions + +This phase defines WHAT to experiment on. By now you have full context from +Phases 1-4 (pipeline structure, metrics, code, data, pitfalls). Use that +context to make this phase productive. + +**Introspect:** Read pipeline args, config files, component implementations +to identify tunable parameters. + +**Present:** "I found these configurable parameters: [list with current values]. +Changes are applied via [mechanism]." + +**Ask via AskUserQuestion** — "Auto-research" (researcher does gap analysis, +literature search, code tracing to identify high-impact experiment directions +— NOT just parameter tuning) or "I'll answer": +1. What would you try first if you were running experiments manually? +2. Walk me through how you'd change one parameter end-to-end. +3. What's off-limits? +4. Which experiments are cheap vs expensive? +5. Should Tangent auto-research before each experiment round? What search + terms or code directories should it examine? + +**Auto-research is especially valuable here** — the researcher agent uses +everything from prior phases (code paths, metrics, pitfalls) to produce +Tier 1 (missing capabilities), Tier 2 (methodology improvements), and +Tier 3 (parameter tuning) recommendations with evidence. + +**STOP. Checkpoint:** "Search space: [params + ranges]. Off-limits: [list]. +Research config: [enabled/disabled, terms]. Correct?" + +After confirmation, write a skill file covering experiment techniques. + +### Gate — do NOT proceed to Phase 6 until all pass: +- [ ] Showed configurable parameters with current values +- [ ] Asked what user would try first manually (or auto-researched) +- [ ] Asked how to change a parameter end-to-end (or auto-researched) +- [ ] Asked what's off-limits (or auto-researched) +- [ ] Asked which experiments are cheap vs expensive (or auto-researched) +- [ ] Asked about auto-research config for experiment rounds +- [ ] User confirmed checkpoint summary +- [ ] Experiment techniques skill file written + +--- + +## Phase 6: Budget & Convergence + +**Ask:** +1. How many total runs? (suggest 15-20 for first experiment) How many + parallel? How many rounds? +2. How long does one full pipeline run take? +3. What improvement is worth shipping? + +**STOP. Checkpoint:** "Budget: [N] runs, [M] parallel, [R] rounds. +Min improvement: [X]. Pipeline takes ~[T]. Correct?" + +### Gate — do NOT proceed to Generation until all pass: +- [ ] Asked about total runs, parallel runs, and rounds +- [ ] Asked about pipeline runtime +- [ ] Asked about minimum improvement worth shipping +- [ ] User confirmed checkpoint summary + +--- + +## Generation (ONLY after all phases complete) + +Now — and only now — write the remaining files: + +### scenario.yaml + +Assemble from all phase outputs. Add comments explaining choices where the +user gave reasoning. Use the template at the bottom of this file. + +### MEMORY.md + +Initialize with: +- Baseline metric values (Phase 2) +- Known priors and prior experiment results (Phase 1) +- Pitfalls and things to avoid (Phase 4) +- Empty "Best Known Config", "Key Lessons", "Session Index" sections + +### pipeline.yaml + +Already exported in Phase 1. + +### skills/ + +Already written incrementally after Phases 2-4. + +--- + +## Final Validation + +Show the user a summary of ALL generated files: +- scenario.yaml — metrics, search space, guardrails, budget +- MEMORY.md — baseline stats, priors, pitfalls +- skills/ — list each file with 1-line summary +- pipeline.yaml — exported from run [ID] + +Ask: "Everything look right? Ready to run Tangent on this scenario?" + +--- + +## Reference: scenario.yaml Template + +```yaml +name: "" +description: > + <2-3 sentences: what pipeline, what model, what problem> + +pipeline: + path: pipeline.yaml + baseline_run_id: "" + source_path: "" + +metrics: + target: + path: "" + task: "" + direction: maximize # or minimize + description: "" + secondary: + - path: "" + task: "" + description: "" + guards: + - path: "" + task: "" + min_value: # or max_value for minimize + description: "" + +search_space: + : + type: continuous # or discrete, categorical + range: [, ] # or values: [a, b, c] + current: + +experiment_actions: + : + description: "" + mechanism: "" + actions: + - name: "" + description: "
" + how: "" + +research: + enabled: true # or false + code_paths: [""] + github: + repo: "" + search_terms: [""] + history_days: 90 + +budget: + max_parallel_runs: 4 + max_total_runs: 20 + max_rounds: 6 + convergence: + min_improvement: 0.003 + patience_rounds: 2 + +timing: + total_seconds: + +failure_playbook: + - type: + detect: "" + task_pattern: "" + action: "" + counts_against_budget: + max_retries: +``` diff --git a/skills/tangent/references/data-sources.md b/skills/tangent/references/data-sources.md new file mode 100644 index 0000000..89ed00d --- /dev/null +++ b/skills/tangent/references/data-sources.md @@ -0,0 +1,334 @@ +# Promoting and reusing data sources + +> **Conditional pattern — backend-dependent.** The data-source components +> described here are **not part of the OSS `tangle` core**. They are a *pattern* +> some backends provide for preserving a run's output for indefinite reuse. If +> your backend does **not** register components like these, skip this file and +> use the lightweight reuse pattern in [Gotchas](#gotchas) instead: record a +> producer run's `run_id` and the artifact `uri` locally, and re-reference them. +> Never treat the data-source family as a prerequisite for any workflow step. + +*If your backend provides components like these*, the canonical way to preserve a +Tangle run's output for indefinite reuse — or to consume someone else's preserved +artifact in a new run — is a **data-source family of components**: + +| Component (if provided) | Use it to | Inputs → Outputs | +|---|---|---| +| `Promote to data source` | Snapshot a local file/directory artifact into a stable, ID-addressable storage location with provenance metadata | `input` (`Data`), `resource_name` (`String`) → `data_source_id` (`String`) | +| `Load data source` | Materialize a previously-promoted snapshot back into a downstream task | `data_source_id` (`String`) → `output` (`Data`), `metadata` (`Json`) | +| `Find data source` | Resolve the latest `data_source_id` for a known `(author, resource_name)` pair, optionally bounded by created-at date | `author` (`String`), `resource_name` (`String`), optional `min_created_at` / `max_created_at` (`String`) → `data_source_id` (`String`) | + +A "data source" is opinionated about *preservation*: every promote records +a verbatim provenance metadata blob alongside the payload (producer's +identity, timestamp, source kind, file count, the producing `pipeline_run_id`, +…) so a consumer can always trace data back to the experiment that made +it. Where the bytes actually live and how the ID is constructed are +internal to the components — treat the `data_source_id` as opaque. + +This is **different from `uploading-artifacts.md`**: + +- Generic ingest (staging a local file for a single experiment) covers + **ephemeral ingest** — no provenance, no naming convention beyond a + per-session prefix. +- `Promote to data source` / `Load data source` / `Find data source` + cover **persistent reuse** — a curated artifact you want to find, + share, and re-run against next week or next quarter. + +Use the lightweight ingest path when the artifact only matters for one +experiment. Promote when you would be sad to lose the artifact, or when you +expect another experiment (yours or someone else's) to consume it. + +## ⚠️ Sensitive data — read before promoting + +**Promoted data persists indefinitely and every backend user may be able to +read it.** Treat every promote like a public publish to the entire user +base: anyone may be able to `Load data source` your `data_source_id`, today or +years from now. Promotes are also hard to undo — once a `data_source_id` +is published and another pipeline depends on it, deleting the underlying +payload breaks that pipeline. + +**Do NOT promote artifacts that contain:** + +- PII (names, emails, addresses, payment data, IPs, device IDs that + resolve to people). +- Confidential or contractually restricted data. +- Embargoed model weights, unreleased product information, internal-only + scoring rubrics where a leak could change behaviour. +- Anything that would require special authorization to query elsewhere — + it requires the same care here. +- Credentials, tokens, keys, anything sourced from `secrets`. + +**If you are not sure, do not promote.** Ask the user. If the artifact +is mixed (small public summary alongside a sensitive raw dataset), +promote only the cleaned subset and leave the raw data in a private, +access-controlled location. + +When in doubt, escalate to the data owner before promoting. + +## ⚠️ Do NOT recurrently promote + +**A pipeline that includes `Promote to data source` should not be driven by a +recurring trigger.** Every run would mint a fresh snapshot that persists +indefinitely — a daily cadence produces 365 effectively-identical snapshots a +year, all loadable by every user, none of which the storage will ever expire +on its own. That floods the storage and makes legitimate `Find data source` +queries useless. + +> There is no scheduler command in the OSS core; this is a conceptual caution +> about wiring promote into *any* recurring or automated submission path you +> may build downstream. + +Promote belongs in **interactive or one-off submissions**: a human (or +an agent on a human's behalf) decided this particular artifact is worth +preserving. If you genuinely need a periodically-refreshed data source +— e.g. a weekly catalog snapshot — get explicit sign-off from the user +*and* a plan for cleaning up older snapshots before wiring promote into +a recurring pipeline. + +When iterating on a pipeline that contains a promote task, drop the +promote (or short-circuit it) until you're submitting a run whose output +you actually want preserved. + +## The `data_source_id` + +The `data_source_id` is an opaque string identifying one snapshot. +`Promote to data source` and `Find data source` both *emit* one; +`Load data source` *consumes* one. Pass it verbatim — don't parse or +construct it yourself. Save it to the scenario's `MEMORY.md` or session +log so other runs can reuse it. + +(`Find data source` is keyed by `(author, resource_name)` — not by +`data_source_id` — see Recipe D.) + +A re-promote of the same `resource_name` **never overwrites** an earlier +snapshot — you always get a fresh `data_source_id`, and the previous one +remains loadable. + +## Recipe A — Promote a Tangle task output, so it can be reused + +When a task produces an artifact (curated eval set, fine-tuned model +checkpoint, generated annotations) that you want to keep beyond the +run's TTL or share with other scenarios. + +1. **Confirm the artifact is safe to publish** — see the sensitive-data + warning above. If unsure, ask the user before adding the promote task. + +2. **Add `Promote to data source` downstream** of the producer task: + + ```yaml + tasks: + promoteEvalSet: + componentRef: + name: "Promote to data source" + # Optional: pin by digest after first use for reproducibility. + arguments: + input: + taskOutput: {taskId: buildEvalSet, outputName: dataset} + resource_name: + constant: "ranking-eval-v3" + ``` + +3. **Optional: capture the resulting `data_source_id` after the run.** + Two ways: + + - **Read the promote task's logs.** The component prints `PROMOTED. + data_source_id = ` near the end of its own task log — fetch + with `uv run tangle sdk pipeline-runs logs `. Simplest + and works without modifying the pipeline. + - **Wire it into a downstream task.** Connect the promote task's + `data_source_id` output (a `String`) into another task that prints + or annotates it. Useful if you want the value to flow into the + pipeline graph (e.g. a follow-up `Load data source` in the same + run) rather than be recovered out-of-band. + + Don't try to recover the value with `uv run tangle sdk artifacts get` — + that returns artifact *metadata* (`uri`/`size`/`hash`), not the contents + of scalar `String` outputs. + +4. **Record it.** Once the run completes, add the `data_source_id` to the + scenario's `MEMORY.md` (or the session log) so the next round — or a + teammate — can reference it without re-discovering it. + +## Recipe B — Promote a local file (ingest first, then promote) + +`Promote to data source` consumes a pipeline task output (`Data`), not a +local filesystem path. If you're starting with a file or directory on +your workstation — a curated eval set you built locally, a checkpoint +you downloaded from elsewhere, an annotation bundle — first get the bytes +into a task output, **then** run a one-off promote pipeline. + +1. **Confirm the artifact is safe to publish** — same checklist as + Recipe A. If unsure, ask the user. + +2. **Ingest the local artifact** so it becomes a pipeline task output. + Follow the generic ingest pattern in `uploading-artifacts.md` + (file-vs-directory trailing-slash semantics, unique-filename guidance, + and the `componentRef` wiring shape). The exact ingest component depends + on what your backend provides; treat its output (`Data`) as the input to + the promote task. + +3. **Build a 2-task promote pipeline** wiring your ingest task into + `Promote to data source`: + + ```yaml + tasks: + ingestEvalSet: + componentRef: + name: "" + arguments: + # ...ingest args per uploading-artifacts.md... + promoteEvalSet: + componentRef: + name: "Promote to data source" + arguments: + input: + taskOutput: {taskId: ingestEvalSet, outputName: Data} + resource_name: + constant: "ranking-eval-v3" + ``` + +4. **Submit it as a one-off interactive run** — do **not** wire this into + a recurring trigger (see the prohibition above; every recurring run would + mint a fresh permanent snapshot). After completion, recover the + `data_source_id` via the promote task's logs (Recipe A step 3) and + record it in `MEMORY.md`. + +The `builder` agent's "Preserving a run's output as a reusable +data source" workflow has more detail on wiring and submission, and +`uploading-artifacts.md` walks the ingest step. + +## Recipe C — Load someone else's (or a previous round's) data source + +When a downstream task needs an artifact that was previously promoted — +for example, a baseline eval set, a frozen feature snapshot, a model +checkpoint another scenario published. + +1. **Get the `data_source_id`.** Three ways: + + - It was recorded in your scenario's `MEMORY.md` / session log from an + earlier run. + - A teammate handed it to you directly. + - You discovered it with `Find data source` (see Recipe D). + +2. **Add `Load data source`** as a graph input or upstream task: + + ```yaml + tasks: + loadEvalSet: + componentRef: + name: "Load data source" + arguments: + data_source_id: + constant: "" + # Or graphInput: {inputName: eval_set_id} to make it a run-config parameter. + + myEvaluator: + arguments: + eval_data: + taskOutput: {taskId: loadEvalSet, outputName: output} + eval_metadata: + taskOutput: {taskId: loadEvalSet, outputName: metadata} + ``` + +3. **Inspect the metadata if needed.** The `metadata` output is the + verbatim provenance record — useful when a downstream task needs to + confirm `source_kind`, see when the data was produced, or chain back + to the producing `pipeline_run_id`. + +## Recipe D — Find an existing data source before promoting your own + +Before promoting a new snapshot, check whether something similar already +exists. Reuse beats duplication. + +`Find data source` is a pipeline component, not a CLI command. Wire it +upstream of `Load data source` and let it resolve to the latest snapshot +matching an `(author, resource_name)` pair, optionally bounded by date. +The `author` is the sanitized form of the producing user's identity — +e.g. a `first.last` local part typically resolves to `first-last` (dots +become dashes). Try a few likely variations if the first guess misses. + +```yaml +tasks: + findEvalSet: + componentRef: + name: "Find data source" + arguments: + author: + constant: "first-last" + resource_name: + constant: "ranking-eval-v3" + # Optional, both inclusive, ISO date or full datetime: + # min_created_at: + # constant: "2026-05-01" + # max_created_at: + # constant: "2026-05-31" + + loadEvalSet: + componentRef: + name: "Load data source" + arguments: + data_source_id: + taskOutput: {taskId: findEvalSet, outputName: data_source_id} +``` + +`Find data source` fails the task (and writes no output) if no snapshot +matches — so if your pipeline branches on "exists vs. needs producing", +keep the find/load chain in a sub-pipeline that's safe to fail, or run +it in a separate exploratory submission first to confirm the ID before +wiring it into the main flow. + +If you don't have an `(author, resource_name)` guess at all, check the +scenario's `MEMORY.md` and session logs for previously-recorded +`data_source_id`s, or ask the teammate who ran the producing experiment. +There is no general "list everything" path — discovery is intentionally +scoped through `Find data source`. + +## Recipe E — Pass `data_source_id` through the run config + +Declare the ID as a top-level graph input, wire it to `Load data source`, +and supply it per submission with `--arg` (or `--args-json`): + +```yaml +# pipeline.yaml +inputs: + - name: eval_set_id + type: String + +tasks: + loadEvalSet: + componentRef: + name: "Load data source" + arguments: + data_source_id: + graphInput: {inputName: eval_set_id} +``` + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg eval_set_id="" +``` + +## Gotchas + +- **No data-source components? Use the lightweight pattern.** If your + backend doesn't register these components, you can still reuse a prior + run's output: record the producer's `run_id` and the artifact `uri` + (read it scheme-agnostically from `uv run tangle sdk artifacts get RUN_ID`) + in `MEMORY.md`, and re-reference that `uri` directly. URIs are + backend-agnostic and may look like `hf://datasets//@main/` + — read the `uri` field; do not assume a scheme. +- **`Load data source` reproduces the original shape.** If the producer + promoted a single file, the `output` is a single file. If a directory, + `output` is a directory tree. The consumer must read it accordingly. +- **`Find data source` returns "latest" by creation timestamp.** If you + want a frozen, version-pinned reference, copy the `data_source_id` into + `MEMORY.md` or pass it through the run config (Recipe E) instead of + resolving it dynamically every run. +- **The metadata's `pipeline_run_id` is the producer's run, not yours.** + Useful for tracing provenance from a downstream consumer back to the + experiment that generated the artifact. +- **Promote is for outputs another run will load.** Don't promote + one-shot artifacts — let the run's TTL clean them up. Promotes persist + indefinitely and may be visible to every backend user. +- **Don't store credentials, tokens, or anything sourced from + `secrets`.** See the sensitive-data warning above and `secrets.md`. diff --git a/skills/tangent/references/event-log.md b/skills/tangent/references/event-log.md new file mode 100644 index 0000000..06f246f --- /dev/null +++ b/skills/tangent/references/event-log.md @@ -0,0 +1,110 @@ +# Event Log + +Append-only structured event log. One JSON object per line. +Path: `/logs/events.jsonl` + +## Step Name Mapping + +| Step | Name | +|------|------| +| 0 | `initialize` | +| 1 | `analyze` | +| 2 | `hypothesize` | +| 3 | `submit` | +| 4 | `monitor` | +| 5 | `evaluate` | +| 6 | `synthesize` | +| 7 | `decide` | + +## Event Types + +**step_transition** +```json +{"type": "step_transition", "ts": "2025-01-15T10:30:00Z", "step": 1, "step_name": "analyze", "status": "complete", "round": 1} +``` + +**tool_call** — log the call, but do NOT save the output (it bloats the log and is +rarely re-read). Only record tool name, args, and duration. +```json +{"type": "tool_call", "ts": "2025-01-15T10:31:00Z", "tool": "get_run_details", "args": {"run_id": "abc123"}, "duration_ms": 1200} +``` + +**hypothesis** +```json +{"type": "hypothesis", "ts": "2025-01-15T10:32:00Z", "round": 1, "experiment_type": "parameter_tuning", "description": "Sweep learning rate 0.03-0.15", "expected_outcome": "+0.5% target metric", "num_runs": 4, "budget_impact": 4} +``` + +**run_submit** +```json +{"type": "run_submit", "ts": "2025-01-15T10:33:00Z", "round": 1, "run_id": "abc123", "experiment_type": "parameter_tuning", "label": "", "config_changes": {"learning_rate": 0.05}, "annotations": {"session": "2025-01-15-", "round": "1"}, "pipeline_args": {}} +``` + +**run_complete** +```json +{"type": "run_complete", "ts": "2025-01-15T11:15:00Z", "round": 1, "run_id": "abc123", "target_metric": 0.4523, "guard_metrics": {"metric_a": 0.412}, "all_metrics": {}, "vs_baseline": 0.0023, "vs_best": 0.0023} +``` + +**run_failed** +```json +{"type": "run_failed", "ts": "2025-01-15T11:10:00Z", "round": 1, "run_id": "def456", "failure_type": "INFRA", "failed_task": "", "error_summary": "Pod evicted", "resolution": "retry", "retry_run_id": "ghi789", "snapshot_path": "logs/failures/def456.md"} +``` + +**analysis** +```json +{"type": "analysis", "ts": "2025-01-15T11:20:00Z", "round": 1, "run_id": "abc123", "analysis_type": "error", "findings": ["Segment X regressed 2%", "Segment Y improved 1.5%"], "proposed_direction": "Try feature pruning for weak segment features"} +``` + +**finding_promoted** +```json +{"type": "finding_promoted", "ts": "2025-01-15T11:25:00Z", "round": 1, "finding": "LR=0.05 optimal for this model size", "source_run_id": "abc123"} +``` + +**round_end** +```json +{"type": "round_end", "ts": "2025-01-15T11:30:00Z", "round": 1, "best_run_id": "abc123", "best_metric": 0.4523, "improvement_vs_baseline": 0.0023, "improvement_vs_prev_best": 0.0023, "budget_remaining": 11, "num_runs_this_round": 4} +``` + +**memory_compaction** +```json +{"type": "memory_compaction", "ts": "2025-01-15T11:31:00Z", "lessons_before": 12, "lessons_after": 8, "tokens_before": 3200, "tokens_after": 2400, "archived_to": "sessions/2025-01-15.md"} +``` + +**research_complete** +```json +{"type": "research_complete", "ts": "2025-01-15T10:15:00Z", "tracks_completed": ["shipping", "code", "baseline", "issues", "literature", "data"], "tracks_skipped": ["slack"], "brief_path": "research-brief.md", "directions_count": 7, "duration_seconds": 180} +``` + +**report_generated** +```json +{"type": "report_generated", "ts": "2025-01-15T12:00:00Z", "report_path": "case_studies/2025-01-15-.md", "outcome": "SUCCESS", "baseline_metric": 0.45, "best_metric": 0.458, "improvement_pct": 1.78, "total_runs": 12, "total_rounds": 4} +``` + +**learning_record_failed** — emitted when a write to the learnings corpus fails +(disk, network, quota, or permissions on the shared tier). The local file is the +source of truth; a future session can retry. `artifact` is `"research_brief"` or +`"learning"`; `key_run_id` is the run_id used to key the corpus path (active_run_id +for research, best_run_id for learning). `corpus_uri` is the destination that +failed — under the default local tier this is a `LEARNINGS_DIR` path, under the +optional shared tier an `hf://datasets/...` URI. +```json +{"type": "learning_record_failed", "ts": "2025-01-15T12:05:00Z", "round": 1, "artifact": "learning", "key_run_id": "abc123", "local_path": "logs/learning-abc123.json", "corpus_uri": "hf://datasets//@main//learning-abc123.json", "error_summary": "403 Forbidden: caller is not allowed to write to this dataset repo"} +``` + +## jq Examples + +```bash +# All events for round 2 +jq 'select(.round == 2)' logs/events.jsonl + +# All failures +jq 'select(.type == "run_failed")' logs/events.jsonl + +# Target metric progression +jq 'select(.type == "run_complete") | {run_id, target_metric, vs_baseline}' logs/events.jsonl + +# Promoted findings +jq 'select(.type == "finding_promoted") | .finding' logs/events.jsonl + +# Budget tracking per round +jq 'select(.type == "round_end") | {round, budget_remaining, improvement_vs_baseline}' logs/events.jsonl +``` diff --git a/skills/tangent/references/example-scenarios/01-mslr-ranking.md b/skills/tangent/references/example-scenarios/01-mslr-ranking.md new file mode 100644 index 0000000..b7fb264 --- /dev/null +++ b/skills/tangent/references/example-scenarios/01-mslr-ranking.md @@ -0,0 +1,165 @@ +# Learning-to-Rank on MSLR-WEB10K + +A self-contained, public-data worked example of one autonomous tuning round. It mirrors the +loop in `references/step-0-initialize.md` … `references/step-7-decide.md`: read the situation, +form a hypothesis, express it as a small config delta, submit, watch the right signals, and +write down the outcome. Nothing here is specific to any one backend — every command is the +generic `uv run tangle …` surface from `references/tangle-tools.md`. + +## Dataset (public) + +[**MSLR-WEB10K**](https://www.microsoft.com/en-us/research/project/mslr/) — Microsoft's +Learning-to-Rank benchmark. ~10,000 queries, 5 cross-validation folds, 136 numeric features +per (query, document) pair, graded relevance labels 0–4. It is the canonical public L2R set +(the LETOR family). Released for research use; no credentials and no private data involved. + +For a Tangle scenario the data lives wherever your backend's storage provider puts it. Under +the default OSS backend that is a HuggingFace dataset repo, so the training task reads an input +artifact whose `uri` looks like: + +``` +hf://datasets//mslr-web10k@main/Fold1/train.txt +``` + +The exact scheme is irrelevant to the loop — the pipeline references the artifact by `uri`, +and you read whatever scheme comes back (see `references/uploading-artifacts.md` and the +artifact recipe in `OSS-CONVENTIONS.md` §5). Do **not** assume a particular storage scheme. + +## What it optimizes + +- **Primary**: NDCG@10 on the held-out fold. +- **Secondary**: NDCG@1 (top-of-list quality) and MAP. +- **Per-segment** (optional): track NDCG@10 split by query frequency band if your eval task + emits it — useful for catching a model that wins on average but regresses on rare queries. + +## Pipeline shape + +A minimal ranking pipeline has three leaf tasks: + +``` +generate-featureset → train-ranker → eval-ranker +``` + +- `generate-featureset` — load the fold's `train.txt` / `vali.txt` / `test.txt`, drop + constant/duplicate columns, emit a featureset artifact. +- `train-ranker` — fit a gradient-boosted ranker (e.g. LightGBM with `objective=lambdarank`) + using the LETOR query-group boundaries. +- `eval-ranker` — score the test fold and emit NDCG@1/@10 and MAP as a metrics artifact. + +You do not need a registry, a scheduler, or a promotion gate to run this loop. Those are +extension-only concepts in OSS (see `OSS-CONVENTIONS.md` §10, D9) — treat "promote the winning +model" as out of scope here and just record the winning `run_id`. + +--- + +## Round example + +### Situation + +A baseline run finished. Reading its metrics (`uv run tangle sdk artifacts get -q +'{"tasks":["eval-ranker"]}'`, then fetching the metrics blob via the signed-URL recipe in +`OSS-CONVENTIONS.md` §5): + +- NDCG@10 = **0.452**, NDCG@1 = **0.471**. +- The train metric is far ahead of validation (train NDCG@10 ≈ 0.61) — classic overfitting. +- The trees are deep (`num_leaves=255`) and learning rate is high (`learning_rate=0.1`). +- Truncation for the lambdarank objective is left at its default (~50), while the metric we + actually care about is NDCG@**10**. + +### Hypothesis + +The model overfits and wastes gradient on positions far below the cut. Two cheap, orthogonal +levers: + +1. **Regularize**: lower `num_leaves` and `learning_rate`, raise `min_data_in_leaf`. This + should narrow the train/validation gap. +2. **Align the objective to the metric**: set the lambdarank truncation to 10 so the loss + concentrates on the positions NDCG@10 scores. + +We test them as two separate single-lever runs so the round is attributable — see +`references/step-2-hypothesize.md` (one hypothesis per run). + +### Config deltas + +Run arguments are passed inline at submit time — there is **no** `-f config.yaml` file +(`OSS-CONVENTIONS.md` §10, D12). Start from the baseline pipeline, then override only the +args under test. + +Run A — regularize: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg num_leaves=63 \ + --arg learning_rate=0.05 \ + --arg min_data_in_leaf=200 \ + --annotation session=2026-06-23-mslr-ranking \ + --annotation round=1 \ + --annotation type=regularize \ + --annotation label=leaves63-lr05-mdl200 +``` + +Run B — align truncation to the metric (and a matching `n_estimators` bump to compensate for +the lower learning rate): + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg lambdarank_truncation_level=10 \ + --arg learning_rate=0.05 \ + --arg n_estimators=2000 \ + --annotation session=2026-06-23-mslr-ranking \ + --annotation round=1 \ + --annotation type=objective-align \ + --annotation label=trunc10-lr05-n2000 +``` + +For a structured/nested override use `--args-json ''` (or `--args-json @args.json`) +instead of repeated `--arg`. `submit` returns a `run_id` immediately and does **not** wait +(`OSS-CONVENTIONS.md` §2). Record each `run_id` in your session log and MEMORY before moving on. + +### What to look for + +While the runs are in flight, watch status and graph state with the CLI rather than a Python +poll loop (`OSS-CONVENTIONS.md` §10, D14): + +```bash +uv run tangle sdk pipeline-runs status +uv run tangle sdk pipeline-runs graph-state +``` + +To block until done: `uv run tangle sdk pipeline-runs wait --max-wait 600 +--poll-interval 10`. Container logs (training progress, early-stopping rounds) come from +`uv run tangle sdk pipeline-runs logs `; scheduling/OOM events come from your +launcher's runtime, not from the Tangle backend (`OSS-CONVENTIONS.md` §7). + +When `eval-ranker` completes, read its metrics artifact and check, in order: + +1. **NDCG@10 vs baseline** — did it move beyond run-to-run noise? If your scenario sets + `reps > 1`, compare the *mean* across reps, not a single number. +2. **Train/validation gap** — Run A should have closed it. If the gap is gone but NDCG@10 + didn't improve, you regularized past the sweet spot. +3. **NDCG@1 alongside NDCG@10** — Run B (truncation=10) should help top positions most; make + sure it didn't trade away NDCG@1. +4. **Per-segment** (if emitted) — confirm the average win isn't hiding a rare-query regression. + +### Outcome (illustrative) + +| Run | Lever | NDCG@10 | NDCG@1 | Train gap | +|-----|-------|---------|--------|-----------| +| baseline | — | 0.452 | 0.471 | large | +| A | regularize | 0.460 | 0.474 | small | +| B | truncation=10 | 0.466 | 0.486 | medium | + +Both levers helped; aligning the objective to the metric (Run B) helped more and especially +lifted NDCG@1. The natural next round combines them (regularize **and** truncation=10) as a +single new hypothesis, submitted the same way. Write the win down as a learning keyed by the +best `run_id`: + +```bash +mkdir -p "$LEARNINGS_DIR/mslr-ranking" +cp learning.json "$LEARNINGS_DIR/mslr-ranking/learning-.json" +``` + +`LEARNINGS_DIR` defaults to `$SCENARIO_DIR/learnings/` and is env-overridable; an optional +shared HuggingFace-dataset tier exists for team corpora (`OSS-CONVENTIONS.md` §6 and +`references/knowledge-corpus.md`). Then decide per `references/step-7-decide.md`: keep +iterating, or stop because the round saturated. diff --git a/skills/tangent/references/example-scenarios/02-text-classification.md b/skills/tangent/references/example-scenarios/02-text-classification.md new file mode 100644 index 0000000..cb19d3d --- /dev/null +++ b/skills/tangent/references/example-scenarios/02-text-classification.md @@ -0,0 +1,147 @@ +# Text Classification on AG News + +A second self-contained, public-data worked example — same loop, a different model family. It +shows the situation → hypothesis → config-delta → signals → outcome rhythm applied to a +transformer fine-tuning task instead of a gradient-boosted ranker. Every command is the +generic `uv run tangle …` surface (`references/tangle-tools.md`). + +## Dataset (public) + +[**AG News**](https://huggingface.co/datasets/ag_news) — a widely used public topic- +classification benchmark: 120,000 training and 7,600 test news headlines/descriptions across +4 balanced classes (World, Sports, Business, Sci/Tech). It is small, balanced, and fast to +iterate on, which makes it a good loop-shakedown set. No credentials or private data. + +The training task references the split as an input artifact by `uri` (scheme-agnostic — read +whatever comes back; see `OSS-CONVENTIONS.md` §5): + +``` +hf://datasets//ag-news@main/train.parquet +``` + +## What it optimizes + +- **Primary**: macro-F1 on the test split (robust to any residual class imbalance). +- **Secondary**: accuracy, and per-class F1 (catch a model that's strong on three classes and + weak on the fourth). + +## Pipeline shape + +``` +prepare-data → finetune-classifier → eval-classifier +``` + +- `prepare-data` — load + tokenize the splits with a public base model's tokenizer, emit a + tokenized-dataset artifact. +- `finetune-classifier` — fine-tune a small public encoder (e.g. + [`distilbert-base-uncased`](https://huggingface.co/distilbert-base-uncased)) with a 4-way + classification head. +- `eval-classifier` — score the test split and emit macro-F1, accuracy, and per-class F1 as a + metrics artifact. + +Only public models and datasets appear here. There is no registry/promotion/scheduling step — +those are extension-only in OSS (`OSS-CONVENTIONS.md` §10, D9); record the winning `run_id` +and move on. + +--- + +## Round example + +### Situation + +The baseline fine-tune finished but underwhelms, and the run was expensive: + +- macro-F1 = **0.918**, accuracy = **0.919**. +- Container logs (`uv run tangle sdk pipeline-runs logs `) show validation loss + bottoming out around epoch 2 of 5 and creeping up after — the last 2–3 epochs are wasted + compute and mild overfitting. +- `max_seq_length=256`, but AG News items are short; most sequences are heavily padded. +- `learning_rate=5e-5` with a constant schedule. + +### Hypothesis + +Two orthogonal, low-risk levers: + +1. **Stop wasting epochs**: cut `num_train_epochs` to 3 and add early stopping on validation + macro-F1. Should preserve quality while cutting cost. +2. **Right-size the inputs and smooth the schedule**: drop `max_seq_length` to 128 (fits + nearly all items, roughly halves step cost) and add linear warmup + decay. Faster steps let + us afford a slightly larger effective batch. + +Test them as two single-lever runs so each is attributable (`references/step-2-hypothesize.md`). + +### Config deltas + +Args go inline at submit — no `-f config.yaml` (`OSS-CONVENTIONS.md` §10, D12). + +Run A — fewer epochs + early stopping: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg num_train_epochs=3 \ + --arg early_stopping_patience=1 \ + --arg metric_for_best_model=macro_f1 \ + --annotation session=2026-06-23-text-classification \ + --annotation round=1 \ + --annotation type=cost-trim \ + --annotation label=epochs3-earlystop +``` + +Run B — right-size sequence length + warmup schedule: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg max_seq_length=128 \ + --arg lr_scheduler_type=linear \ + --arg warmup_ratio=0.06 \ + --arg per_device_train_batch_size=64 \ + --annotation session=2026-06-23-text-classification \ + --annotation round=1 \ + --annotation type=throughput \ + --annotation label=seq128-warmup-bs64 +``` + +For nested overrides use `--args-json ''`. `submit` returns a `run_id` and does not wait; +record it, then watch the run. + +### What to look for + +Use the CLI for status, not a Python poll loop (`OSS-CONVENTIONS.md` §10, D14): + +```bash +uv run tangle sdk pipeline-runs status +uv run tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10 +``` + +Application progress (loss curves, early-stop trigger, steps/sec) is in container logs; a +killed-pod / OOM signal comes from your launcher's runtime, not the Tangle backend +(`OSS-CONVENTIONS.md` §7). On completion, read the metrics artifact and check: + +1. **macro-F1 vs baseline** — within noise, better, or worse? With `reps > 1`, compare means. +2. **Cost vs quality (Run A)** — fewer epochs should hold F1 roughly flat while cutting + wall-clock/compute. If F1 dropped meaningfully, patience=1 was too aggressive. +3. **Throughput vs quality (Run B)** — confirm `max_seq_length=128` didn't truncate enough + content to hurt F1; steps/sec in the logs should rise. +4. **Per-class F1** — make sure the "Sci/Tech" class (the usual weak one) didn't regress while + the macro average held. + +### Outcome (illustrative) + +| Run | Lever | macro-F1 | Rel. cost | Notes | +|-----|-------|----------|-----------|-------| +| baseline | — | 0.918 | 1.0× | overfits after ep2 | +| A | epochs=3 + early stop | 0.919 | ~0.55× | same quality, ~half the compute | +| B | seq=128 + warmup | 0.922 | ~0.6× | slight F1 gain, faster steps | + +Both levers are wins and they compose — the next round combines `epochs=3 + early stop` with +`seq=128 + warmup` as a single new hypothesis. Record the best run as a learning keyed by its +`run_id`: + +```bash +mkdir -p "$LEARNINGS_DIR/text-classification" +cp learning.json "$LEARNINGS_DIR/text-classification/learning-.json" +``` + +`LEARNINGS_DIR` defaults to `$SCENARIO_DIR/learnings/` (env-overridable); the optional shared +tier is a HuggingFace-dataset repo (`OSS-CONVENTIONS.md` §6, `references/knowledge-corpus.md`). +Then decide per `references/step-7-decide.md`. diff --git a/skills/tangent/references/example-scenarios/INDEX.md b/skills/tangent/references/example-scenarios/INDEX.md new file mode 100644 index 0000000..21cab7d --- /dev/null +++ b/skills/tangent/references/example-scenarios/INDEX.md @@ -0,0 +1,48 @@ +# Example Scenarios — Public-Data Worked Examples + +> **Purpose**: A small set of self-contained, runnable scenarios on **public datasets**, +> meant as inspiration for an autonomous tuning loop. Each one mirrors the loop in +> `references/step-0-initialize.md` … `references/step-7-decide.md`: read the situation, form +> a single hypothesis, express it as a small config delta, submit, watch the right signals, +> and write down the outcome. + +Every command in these scenarios is the generic `uv run tangle …` surface documented in +`references/tangle-tools.md`. They name only public datasets and public models, store +artifacts scheme-agnostically (read the `uri`; see `OSS-CONVENTIONS.md` §5), and treat +registry / promotion / scheduling / `--run-as` as **extension-only** (`OSS-CONVENTIONS.md` +§10, D9). None of them require component discovery / published-component search — that feature +is off by default in OSS, so any search step is optional and tolerant of empty results +(`OSS-CONVENTIONS.md` §10, D11). + +--- + +## Scenarios + +| # | Scenario | File | Public Dataset | What It Optimizes | Model Family | +|---|----------|------|----------------|-------------------|--------------| +| 1 | [Learning-to-Rank on MSLR-WEB10K](01-mslr-ranking.md) | `01-mslr-ranking.md` | MSLR-WEB10K (LETOR) | NDCG@10 (also NDCG@1, MAP) | Gradient-boosted ranker (LambdaRank) | +| 2 | [Text Classification on AG News](02-text-classification.md) | `02-text-classification.md` | AG News | macro-F1 (also accuracy, per-class F1) | Small public encoder fine-tune | + +--- + +## Cross-Cutting Techniques + +| Technique | Where Used | +|-----------|-----------| +| **Single-lever, attributable rounds** | Both — one hypothesis per run (`references/step-2-hypothesize.md`) | +| **Objective↔metric alignment** | MSLR ranking (lambdarank truncation = NDCG cut) | +| **Regularization vs overfitting** | MSLR ranking (leaves / learning rate / min-data-in-leaf) | +| **Cost-vs-quality trimming** | Text classification (epochs + early stopping) | +| **Throughput right-sizing** | Text classification (sequence length + warmup schedule) | +| **Per-segment / per-class checks** | Both — guard against an average win that hides a slice regression | + +## Loop Patterns (shared by every scenario) + +| Pattern | Details | +|---------|---------| +| **Submit** | Args inline via `--arg K=V` / `--args-json` — no `-f config.yaml` (`OSS-CONVENTIONS.md` §10, D12); hydrate is the default; `submit` never waits | +| **Wait / status** | `pipeline-runs status` and `pipeline-runs graph-state` over a Python poll loop (`OSS-CONVENTIONS.md` §10, D14); block with `pipeline-runs wait` | +| **Logs** | Container logs via `pipeline-runs logs EXECUTION_ID`; scheduling/OOM events from the launcher's runtime, not the backend (`OSS-CONVENTIONS.md` §7) | +| **Artifacts** | Metadata via `artifacts get`; bytes via the signed-URL recipe; URIs are scheme-agnostic, e.g. `hf://…` (`OSS-CONVENTIONS.md` §5) | +| **Annotations** | Generic `--annotation session=… --annotation round=… --annotation type=… --annotation label=…` (`OSS-CONVENTIONS.md` §8) | +| **Learnings** | Record keyed by `run_id` under `$LEARNINGS_DIR//` (default `$SCENARIO_DIR/learnings/`); optional shared HuggingFace-dataset tier (`OSS-CONVENTIONS.md` §6, `references/knowledge-corpus.md`) | diff --git a/skills/tangent/references/iterating-on-runs.md b/skills/tangent/references/iterating-on-runs.md new file mode 100644 index 0000000..8d56487 --- /dev/null +++ b/skills/tangent/references/iterating-on-runs.md @@ -0,0 +1,56 @@ +# Iterating on pipelines from existing runs + +When modifying and re-running an existing pipeline (e.g. change params for failed tasks): + +1. **Export the run**: + ```bash + uv run tangle sdk pipeline-runs export --output /tmp/pipeline.yaml + ``` + This writes the run's root pipeline spec to `/tmp/pipeline.yaml` (omit `--output` to print + to stdout). The export is the spec **as-is** — there is no `--dehydrate` flag and no + separate `.config.yaml`. Run arguments are not exported as a config file; you re-supply + them at submit time with `--arg`/`--args-json`/`--config` (step 6). + +2. **Inspect execution statuses**: + ```bash + uv run tangle sdk pipeline-runs details --include-execution-state + ``` + to identify failed/cancelled/skipped executions. For a quick run + derived status + summary, use `uv run tangle sdk pipeline-runs status `. + +3. **Understand the YAML structure**: Tangle pipelines are nested subgraphs. Inputs flow + through the hierarchy via `graphInput` wiring: top-level task output → subgraph input → + nested subgraph input → leaf task argument. Trace the wiring at each level before + modifying. For pipeline and component schema details, use `uv run tangle sdk pipelines + --help` / `uv run tangle sdk components --help`, browse the curated standard library + (`uv run tangle sdk published-components library`), and consult the public docs at + `github.com/TangleML/website/tree/master/docs`. + +4. **Modify the pipeline**: Edit the exported YAML directly. To replace a component, swap + its component reference (a published `digest:` or a local `url: file://`) with a new + `url: file://` reference pointing to the replacement component file. + +5. **Preview**: render the structure before submitting — + ```bash + uv run tangle sdk pipelines diagram /tmp/pipeline.yaml # Mermaid + uv run tangle sdk pipelines layout /tmp/pipeline.yaml # auto-layout + ``` + and validate it parses: + ```bash + uv run tangle sdk pipelines validate /tmp/pipeline.yaml + ``` + +6. **Submit** (see Submission Rules in `references/tangle-tools.md`): + ```bash + uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + --arg = \ + --annotation session= --annotation round= + ``` + Hydration is the default — the submit resolves component references for you, so there + is no "dehydrate first" guard to run. Supply run arguments inline with `--arg K=V` + (repeatable), or `--args-json ''` for structured/nested args, or a `--config` + file for CLI-option defaults (base-url/auth/log-type — not run args). `submit` never + waits; to block on completion, follow up with: + ```bash + uv run tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10 + ``` diff --git a/skills/tangent/references/knowledge-corpus.md b/skills/tangent/references/knowledge-corpus.md new file mode 100644 index 0000000..364533a --- /dev/null +++ b/skills/tangent/references/knowledge-corpus.md @@ -0,0 +1,128 @@ +# Knowledge Corpus (Learnings) + +Persistent record of what tangent has tried and learned, so future sessions and +other scenarios can reuse prior context. + +This file is the canonical description of the learnings corpus. For the CLI +surface, auth, and the broader conventions it sits inside, see +[OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) (§6, Learnings corpus). + +## Where the corpus lives + +The corpus is a **local directory** by default, with an **optional shared tier** +backed by a HuggingFace dataset repo. + +### Default — local corpus directory + +The corpus directory is configurable via the `LEARNINGS_DIR` environment +variable. When unset it defaults to `$SCENARIO_DIR/learnings/`: + +``` +$LEARNINGS_DIR/ # default: $SCENARIO_DIR/learnings/ +└── / + ├── research-.md # research brief from Step 1 (one per round that runs research) + └── learning-.json # final learning from Step 7 (one per completed round) +``` + +`` is the Tangle pipeline-run ID returned by +`tangle sdk pipeline-runs submit`. + +**Keying rules:** +- `research-.md` — keyed by the **active run_id**: + - Round 1: `active_run_id = baseline_run_id` (research happens before first submit) + - Round 2+ re-research: `active_run_id = prior round's best_run_id` (the parent run + that motivated the new research) +- `learning-.json` — keyed by the round's **best_run_id** (the + best-performing run of the round; in single-run rounds this is the only run). + +### Shared tier — HuggingFace dataset repo (optional) + +For a team-shared corpus (so learnings outlive a single checkout and are +visible across the team), push records to a HuggingFace dataset repo. This is +the OSS-native equivalent of a shared bucket and matches the backend's own +storage provider: + +``` +hf://datasets//@main//.json +``` + +Push with `huggingface_hub`: + +```python +from huggingface_hub import HfApi + +HfApi().upload_file( + path_or_fileobj="learning.json", + path_in_repo="/learning-.json", + repo_id="/", + repo_type="dataset", +) +``` + +The local corpus directory is the default; the HF dataset is an optional shared +tier layered on top. Use the same scenario/run_id keying scheme for both. + +## When to record + +- **Research brief** — Step 1 (Analyze), immediately after the researcher writes + `$SCENARIO_DIR/research-brief.md`. Record once per round that runs research. +- **Final learning** — Step 7 (Decide), after the round's outcome is known. Always + record, even on a regression — negative results are signal too. + +## Record commands + +```bash +# Step 1 — research brief +mkdir -p "$LEARNINGS_DIR/" +cp "$SCENARIO_DIR/research-brief.md" \ + "$LEARNINGS_DIR//research-.md" + +# Step 7 — final learning +mkdir -p "$LEARNINGS_DIR/" +cp "$SCENARIO_DIR/logs/learning-.json" \ + "$LEARNINGS_DIR//learning-.json" +``` + +If recording fails (e.g. the corpus directory is unwritable), log a +`learning_record_failed` event and keep going — the local copy under +`$SCENARIO_DIR/logs/` is the source of truth. A future session can retry the +record. + +When using the shared HF dataset tier, the same resilience applies: if the +upload to the dataset repo fails, log `learning_record_failed`, keep the local +record, and retry later. + +## learning.json shape + +```json +{ + "scenario": "", + "run_id": "", + "session": "YYYY-MM-DD-", + "round": , + "baseline_run_id": "", + "hypothesis": "", + "experiment_type": "", + "config_diff": { ... }, // diff vs baseline config + "primary_metric": { "name": "...", "baseline": ..., "result": ..., "delta": ... }, + "all_metrics": { ... }, // full metric dict + "outcome": "", + "lesson": "", + "next_direction": "" +} +``` + +## Reading prior learnings + +Step 0 may pull recent learnings for the same scenario into context: + +```bash +ls -t "$LEARNINGS_DIR//" | head -20 +``` + +Read individual files directly from the corpus directory. Don't bulk-load the +whole prefix — for a long-running scenario it grows without bound. + +When the shared HF dataset tier is in use, list and fetch per-scenario records +with `huggingface_hub` (`HfApi().list_repo_files(...)` + `hf_hub_download`) +rather than cloning the entire dataset. diff --git a/skills/tangent/references/secrets.md b/skills/tangent/references/secrets.md new file mode 100644 index 0000000..0752e90 --- /dev/null +++ b/skills/tangent/references/secrets.md @@ -0,0 +1,347 @@ +# Secrets & Credentials in Tangle Pipelines + +Tangle has first-class secret management. Use it. **Never** inline an API key, +token, password, OAuth client secret, or any other credential into a pipeline +YAML, a component argument default, or a config file you submit. + +This is a hard rule, not a soft preference. Pipeline YAML and pipeline-run +arguments are stored in plaintext in the Tangle backend, surface in run-detail +views, get copied by `tangle sdk pipeline-runs export`, get logged by the +runner, and end up in agent transcripts. A raw key pasted into an input node +*is a leaked credential*. + +## The rule + +When a pipeline needs a credential (API key, bearer token, OAuth secret, HF +token, etc.): + +1. **Do not read the raw value yourself.** Do not paste it from a source repo, + an encrypted secrets file, env vars, browser sessions, a chat thread, a + vault, or anywhere else into the pipeline. Even if you can see it, treat it + as poison. +2. **Use a Tangle secret reference instead.** Components consume the secret + via `dynamicData.secret.name` in the pipeline YAML; Tangle resolves the + value at task-launch time, inside the container, with no plaintext on the + pipeline spec. +3. **The human creates / rotates the secret value**, via `uv run tangle sdk + secrets create ... --from-env VAR_NAME` (preferred — agent never touches + the value). The agent's job is to identify *that a secret is needed*, *what + to name it*, and *how to wire it through the pipeline*. Not to source the + value. + +If the credential is missing from the user's Tangle account, **stop and ask**. +Do not "temporarily" inline a value to unblock yourself. + +## When does the agent halt? (UX model) + +The agent does **not** halt mid-construction every time it sees a credential +argument. That would make scaffolding LLM/API pipelines miserable and would +break the autonomous loop outright. The actual halt boundary is at *submission*, +not at authoring. Concretely: + +| Stage | What the agent does | Halt? | +|---|---|---| +| 1. Detect a secret is needed | Detection heuristics match (`*_KEY`, `*_TOKEN`, `dynamicData.secret`-typed input, etc.) | No — proceed | +| 2. Wire it into the pipeline YAML | `dynamicData.secret: { name: "X" }`. **Never the value.** | No — proceed | +| 3. Validate the pipeline | `tangle sdk pipelines validate` — passes without the value (it only checks structure) | No — proceed | +| 4. **Submission boundary** | Run the pre-submit credential-grep guard from [`step-3-submit.md`](step-3-submit.md). If the referenced secret doesn't yet exist under the running account, surface a copy-pasteable `tangle sdk secrets create --from-env` command for the human. | **Yes** — hard halt until the human confirms the secret exists | +| 5. After human creates the secret | Resume: `pipeline-runs submit ... --hydrate` | — | + +The agent's job at construction time is to produce a complete, reviewable +artifact (pipeline YAML + scaffolding + a list of exactly which secrets the +human still needs to create). The agent's job at submit time is to refuse to +submit if any credential value is inlined or any referenced secret is missing. + +### Why halt at submit, not construction? + +- **Validation doesn't need the value.** A pipeline file with + `dynamicData.secret: { name: "FOO" }` validates and is safe to share, + commit to a repo, paste in a chat thread. The leak risk only materializes + when a *value* enters the pipeline spec or run arguments — which only happens + at submit. +- **The human gets a full artifact to review.** Half-finished scaffolds plus + an interactive prompt are worse UX than a complete pipeline plus a clear + "run this command before submitting" handoff. +- **The autonomous loop would deadlock otherwise.** It runs unsupervised; + halting on every credential reference per round means no LLM-using scenario + ever advances past round 1. + +### When the agent *should* halt during construction + +One legitimate construction-time halt: when the agent doesn't know the +correct secret *name* and would have to guess. Identifier names are not +credentials, but a wrong name causes a runtime "secret not found" failure +that wastes the user's time and budget. + +- **Interactive session** (human in the loop, e.g. running the builder, the + scenario-builder, or a one-off prompt): if the secret name is not given by + the user and not unambiguously derivable, **halt and ask**. Do not guess + identifier names from your training data or from grepping repos. +- **Autonomous session** (mid-loop): do not halt mid-round. Use an explicit + placeholder like `"REPLACE_ME_"` and let the submit-time gate + catch it. The gate fails fast and surfaces the unmet prerequisite; this is + preferable to a hung loop. + +Distinguish the two by checking the active-runs state in `MEMORY.md`, the +presence of `scenario.yaml`, and whether the entry point was the autonomous +loop vs a one-shot prompt. When in doubt: ask. + +### What "halt and ask" looks like in practice + +Good: + +> I'm wiring a task that needs an `api_key`, and I need to know the **name** of +> the Tangle secret to reference (not the key value itself). +> +> What name should I use? If you don't have one yet, you'll need to create the +> Tangle secret first, then tell me the name. +> +> I'll wait — I won't guess. + +Bad (do not do): + +> I found a key in a source repo — using that. (❌ sourcing) +> I'll default the secret name to something that looks right. (❌ guessing) +> Pasting the API key value as `constantValue` for now — you can rotate later. +> (❌ **never**) + +## Detection — when does this rule fire? + +If an input/argument is described as, named like, or behaves like any of: + +- `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_KEY`, `bearer`, + `authorization`, `client_secret`, `private_key`, `webhook_signing_secret` +- A header value containing `Bearer …`, `Basic …`, or `Authorization: …` +- An HF token, an OpenAI/Anthropic/Google/proxy LLM key, a chat bot token, + a Git host PAT, or any other encrypted/managed credential value +- Anything the source repo reads from `os.environ`, an encrypted secrets + store, or `dynamicData.secret` itself + +…then it is a secret. Route it through `dynamicData.secret`. + +## The end-to-end workflow + +### 1. Discover what secrets exist + +```bash +uv run tangle sdk secrets list +``` + +Output lists `secret_name`, `updated_at`, optional `expires_at`, optional +`description`. The secret **value is never returned** — by Tangle or by you. + +Secrets are **identity-scoped** — they belong to the authenticating identity +that created them. See [Identity scoping](#identity-scoping) below. + +### 2. If the secret is missing, ask the human to create it + +Surface a concrete command for the human to run. Use `--from-env` so the +value never enters the pipeline YAML or your context — **but be careful +about how the env var itself is set**, because a naive `export VAR='value'` +is recorded verbatim in shell history (`~/.bash_history`, `~/.zsh_history`) +and any terminal recording / session capture. + +The safe pattern is `read -rs` (silent read from stdin, never echoed, +never stored in history): + +```bash +# Human runs these — agent does not source MY_API_KEY itself. +# `read -rs` reads stdin silently; the value is never echoed and never +# enters shell history (only `read -rs MY_API_KEY` is recorded, not the value). +read -rs MY_API_KEY # paste the value, press Enter (no echo) +export MY_API_KEY +uv run tangle sdk secrets create MY_API_KEY \ + --from-env MY_API_KEY \ + --description 'Used by for ' +unset MY_API_KEY # clear it from the current shell +``` + +**Anti-pattern — do not propose this form**: +```bash +export MY_API_KEY='…paste value here…' # ❌ value lands in shell history +``` +Even with `HISTCONTROL=ignorespace` (prepend a space) or `set +o history`, +relying on a per-shell config is fragile. `read -rs` works the same way +everywhere and is the recommended form. + +Multi-secret batch (`secrets_config.yaml`): + +```yaml +_defaults: + description: "Managed for " +configs: + - secret_name: OPENAI_API_KEY + from_env: OPENAI_API_KEY + - secret_name: HF_TOKEN + from_env: HF_TOKEN +``` + +```bash +uv run tangle sdk secrets create --config secrets_config.yaml +``` + +**Do NOT** propose `uv run tangle sdk secrets create NAME --value 'sk-…'` with +a value you pulled from somewhere. The `--value` / `-v` flag exists for humans +typing at a prompt, not for agents shuffling credentials between systems. +Prefer `--from-env` / `-e` everywhere. + +### 3. Wire the secret into the pipeline YAML + +If you're unsure *which* argument on a published component consumes the +credential — or how it expects to receive it — inspect the component's schema +first rather than guessing: + +```bash +uv run tangle sdk published-components inspect "" +``` + +That shows each input and its type, so you can tell which argument is +credential-shaped and wire the secret onto the right one. + +On the component argument that consumes the credential, replace the literal +value with a `dynamicData.secret` reference. The `name` must match exactly +what `tangle sdk secrets list` shows (case- and space-sensitive): + +```yaml +tasks: + call_llm: + componentRef: + name: "LLM Inference" + arguments: + prompt: + taskOutput: + taskId: build_prompt + outputName: prompt + api_key: + dynamicData: + secret: + name: "OPENAI_API_KEY" # ← exactly as listed by `tangle sdk secrets list` + base_url: + constantValue: "https://api.openai.com/v1" +``` + +Things to verify after wiring: + +- `uv run tangle sdk pipelines validate ` passes. +- Run the **complete 4-stage pre-submit gate** from + [`step-3-submit.md`](step-3-submit.md) § "Pre-submit checks". The gate + uses `grep -lEi` (filenames only, never echoes matching lines) plus a + placeholder scan and a Tangle-secret existence check. **Do not** invent + ad-hoc verification commands like `grep -E '(sk-|Bearer |...)' file` — + that variant prints the matching line to stdout, which re-leaks the value + into your terminal, agent transcript, and shell history. Always use the + `-lE` form (or open the file in an editor) when checking for credential + shapes. +- The argument is NOT also set elsewhere (e.g. via `--arg` / `--args-json`) + with a literal value. Run args override `dynamicData` at some call sites; + double-check the effective value. + +### 4. Author the component to consume the secret + +If you're also writing the component code, accept the secret as a regular +string argument and **degrade gracefully when unset** — the component should +not crash if Tangle resolves the secret to empty (e.g. the secret was +deleted, or a teammate runs the pipeline under an identity that doesn't have +it): + +```python +def call_llm(prompt: str, api_key: str = "", base_url: str = "") -> dict: + if not api_key: + raise ValueError( + "Missing api_key. Create the Tangle secret and reference it via " + "dynamicData.secret on this argument." + ) + ... +``` + +Don't log the value. Don't echo it back as an output. Don't write it to an +artifact. + +## Identity scoping + +Secrets in Tangle belong to the authenticating identity that created them — a +secret created under one `--token` / credential is not visible to a run that +authenticates as a different identity. If you need a secret to exist under a +different identity, re-run `secrets create` while authenticating as that +identity (a different `--token` / `--auth-header`; see +[`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §4 for the auth flags). + +For the same reason, **never** `export TANGLE_API_TOKEN='…value…'` literally +in the shell — that lands in `~/.bash_history` / `~/.zsh_history`. If you need +a token for a one-off command, source it via `read -rs` the same way as +`MY_API_KEY` above. + +If a run fails with a "secret not found" or empty-value error, the first thing +to check is *whose* identity holds the secret. Symptom: the pipeline works when +you submit it under your own credential but fails when it runs under a +different one. + +## Anti-patterns — refuse all of these + +- "I'll just paste the value into an input field on the run page so the + pipeline can pick it up." ❌ The value is now in the run spec, visible in + run details, exported by `pipeline-runs export`, and copied by clones. +- "I'll set the value as a `constantValue:` on the argument, since it's just + a one-off run." ❌ Same as above — `constantValue` is plaintext. +- "I'll bake it into the component's Docker image / a config file embedded in + the image." ❌ The image is mirrored, cached, and pullable by anyone with + registry read. +- "I'll pass it via `--arg` / `--args-json`." ❌ Treated as pipeline + arguments — same plaintext exposure. +- "I'll add it as a `cli_args:` value on a downstream task." ❌ Treated as + pipeline arguments — same plaintext exposure. +- "It's only the staging key, so it's fine." ❌ Staging keys are still + credentials. Use a secret. +- "I'll create the secret with `--value` using the key I just read from an + encrypted store." Partial credit — the secret reference is right, but you've + now written the plaintext into shell history and possibly logs. Use + `--from-env` and have the human export the var. + +## Working example + +A minimal end-to-end demo against a public bearer-token echo endpoint +(`httpbin.org/bearer`): + +```bash +# 1. Create the secret (human runs this — DEMO_BEARER_TOKEN can be any string). +# Use --from-env so the value never lands in shell history. +read -rs DEMO_BEARER_TOKEN +export DEMO_BEARER_TOKEN +uv run tangle sdk secrets create DEMO_BEARER_TOKEN \ + --from-env DEMO_BEARER_TOKEN --description 'demo' +unset DEMO_BEARER_TOKEN + +# 2. Submit — the pipeline references DEMO_BEARER_TOKEN via dynamicData.secret +uv run tangle sdk pipeline-runs submit secrets_demo_pipeline.yaml --hydrate + +# 3. Cleanup +uv run tangle sdk secrets delete DEMO_BEARER_TOKEN +``` + +The pipeline injects the secret as `Authorization: Bearer ` to +`httpbin.org/bearer`, and a second task confirms the auth header round-tripped +correctly. Inspect that pipeline YAML for the canonical `dynamicData.secret` +shape. + +## CLI reference + +```bash +uv run tangle sdk secrets list +uv run tangle sdk secrets create NAME --from-env ENV_VAR \ + [--description '…'] [--expires-at 2026-12-31T00:00:00Z] +uv run tangle sdk secrets update NAME --from-env ENV_VAR +uv run tangle sdk secrets delete NAME [--force] +uv run tangle sdk secrets --help +``` + +`create` / `update` take `--value` / `-v` or (preferred) `--from-env` / `-e`, +plus `--description` / `-d` and `--expires-at`. `delete` prompts unless +`--force`. All `secrets` subcommands accept `--config` for multi-secret config +files (see the `_defaults` / `configs` block above). + +## When in doubt + +Stop and ask the human. "This pipeline needs an `X_API_KEY` — please create +a Tangle secret named `` (`uv run tangle sdk secrets create +--from-env `) and confirm before I wire it through" is always the +right move. Never the wrong move. diff --git a/skills/tangent/references/setup.md b/skills/tangent/references/setup.md new file mode 100644 index 0000000..5799a0f --- /dev/null +++ b/skills/tangent/references/setup.md @@ -0,0 +1,146 @@ +# Tangent / Autoresearch Setup + +Tangent uses the **Tangle CLI** (`tangle`, package `tangle-cli`) to run ML +pipelines against a Tangle API backend. Skills live in-repo (checked into the `tangle-cli` repo), so there is no separate sync or bundle-refresh step — relative +cross-references resolve directly on disk. + +> See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) for the single source of +> truth on the CLI surface, auth, artifacts, and the resolved defaults. This file +> only covers install + connect + verify. + +## 1. Install / run the CLI + +The CLI is consumed from a checkout of the repo. Run every command as +`uv run tangle …`: + +```bash +uv run tangle quickstart +uv run tangle --help +uv run tangle sdk --help +uv run tangle api --help +``` + +`uv` resolves dependencies against public PyPI. In the `tangle-cli` workspace, `uv` +installs the workspace `tangle-api` package automatically for dev/tests. The +`[native]` extra enables the static API-backed commands and the handwritten +`TangleApiClient` wrapper. + +> Once `tangle-cli` is promoted to the public OSS package, you will be able to +> `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, +> use `uv run tangle …` from a checkout of the repo. + +Then discover available commands: +```bash +uv run tangle quickstart +``` + +Help is standard `--help` (there is no `--help-extended` / `--help-full`). + +## 2. Configure the backend + +Auth is **explicit and layered**: an explicit CLI option beats a `--config` file +value, which beats an environment default. There is no `auth` command group — you +point the CLI at a backend and attach a credential. + +| CLI option | Env var(s) | Purpose | +|---|---|---| +| `--base-url` | `TANGLE_API_URL` | API origin. Defaults to the local dev 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 (single object, a list, or `_defaults` + `configs`). | +| — | `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics. | + +Pick **one** credential mechanism: + +```bash +# Bearer-token shorthand +export TANGLE_API_URL=https://api.example +export TANGLE_API_TOKEN='…' + +# …or a full Authorization header (Basic / custom schemes) +export TANGLE_API_AUTH_HEADER='Bearer …' + +# …or arbitrary extra headers (e.g. a gateway auth header) +export TANGLE_API_HEADERS='X-Gateway-Auth: …' +``` + +Or pass them per-command: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --base-url https://api.example \ + --auth-header 'Bearer …' \ + -H 'X-Gateway-Auth: …' \ + --log-type console +``` + +If you omit `--base-url` / `TANGLE_API_URL`, the CLI targets the local dev backend +(`http://localhost:8000`), which needs no credentials. For a guided walkthrough of +picking a credential mechanism and interpreting auth failures, see +[`../agents/auth-wizard.md`](../agents/auth-wizard.md). + +## 3. Verify access + +Run a cheap, read-only call. If it returns (even with zero results), your backend +URL and credentials are wired correctly: + +```bash +uv run tangle sdk pipeline-runs search --limit 1 +``` + +Interpreting failures: + +| Response | Meaning | +|---|---| +| `401 Unauthorized` | Missing/invalid credential — check `--token` / `--auth-header` / `-H`. | +| `403 Forbidden` | Authenticated, but not permitted — wrong identity or scope. | +| `429 Too Many Requests` | Rate-limited — back off and retry. | +| Connection error | Wrong/unreachable `--base-url`; confirm the backend is running. | + +## 4. Running commands + +The unified CLI is `tangle`, split into `tangle sdk …` (hand-written +SDK/local/compound commands) and `tangle api …` (auto-generated API wrappers): + +```bash +uv run tangle quickstart +uv run tangle sdk pipeline-runs submit pipeline.yaml --arg key=value +uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state +uv run tangle sdk pipeline-runs logs EXECUTION_ID +uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {"TaskName": ["output"]}}' +``` + +For checking run status, see [`tangle-tools.md`](tangle-tools.md) — prefer the +light status summary (`tangle sdk pipeline-runs status RUN_ID`) and graph-state +(`tangle sdk pipeline-runs graph-state EXECUTION_ID`) for polling over the heavy +`details … --include-execution-state` payload. + +**Do not memorize a static command list.** Run `uv run tangle quickstart` to +discover commands, and `uv run tangle sdk --help` for detailed usage. + +## 5. Artifacts + +Use `tangle sdk artifacts` for artifact metadata — it returns records of the form +`{id, uri, size, hash}`. The `uri` is backend-agnostic (under the OSS `tangle` +backend it is a HuggingFace `hf://…` URI), so read the `uri` field rather than +assuming a scheme: + +```bash +uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {"TaskName": ["output"]}}' +``` + +`artifacts get` is **metadata-only** — there is no download-to-disk command. To +fetch bytes, use the signed-URL recipe in +[`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5 (`artifacts get` → +`tangle api artifacts signed-artifact-url` → fetch with `curl` or `huggingface_hub`). + +## 6. Troubleshooting + +| Problem | Fix | +|---|---| +| `401 Unauthorized` from the verify call | Set a credential: `TANGLE_API_TOKEN`, `TANGLE_API_AUTH_HEADER`, or `-H`. See §2. | +| `403 Forbidden` | Authenticated but not permitted — re-run with a different `--token` for the right identity. | +| Connection error / timeout | Wrong or unreachable `--base-url` / `TANGLE_API_URL`; confirm the backend is up. | +| Want to see the raw HTTP exchange | Set `TANGLE_VERBOSE=1` for redacted request/response diagnostics. | +| Import error from `from tangle_cli.client import TangleApiClient` | Install the `[native]` extra (the client needs the native bindings). | diff --git a/skills/tangent/references/step-0-initialize.md b/skills/tangent/references/step-0-initialize.md new file mode 100644 index 0000000..405b9c5 --- /dev/null +++ b/skills/tangent/references/step-0-initialize.md @@ -0,0 +1,110 @@ +# Step 0: Initialize + +Pure setup — load everything before the experiment loop. No analysis, no decisions. + +## Set up the `tangle` CLI + +Before anything else, make sure you can invoke the CLI. The skills drive the OSS +core, run from a checkout of the `tangle-cli` repo: + +```bash +uv run tangle --help +``` + +If that fails, work through `references/setup.md` first (checkout, `uv sync`, the +`[native]` extra, base-url/auth). Once `uv run tangle --help` works, discover the +available commands: + +```bash +uv run tangle quickstart +``` + +> Once `tangle-cli` is promoted to the public OSS package you will be able to +> `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, +> use `uv run tangle …` from a checkout of the repo. + +## Scenario Directory + +All experiment state lives in the scenario directory. Set the absolute path: + +``` +SCENARIO_DIR= +``` + +All `tangle` commands run via `uv run tangle …`. All file reads/writes use +absolute `SCENARIO_DIR` paths. These are different locations — don't confuse them. + +## No Scenario Yet? Build One + +If the user doesn't have a scenario directory, or `scenario.yaml` doesn't exist +at `SCENARIO_DIR`, **run the scenario builder interview yourself**. + +Read `agents/scenario-builder.md` and follow its instructions directly — do NOT +spawn it as a subagent. The scenario builder is a multi-turn interview that +requires user interaction at every phase. Subagents cannot interact with the +user, so spawning it would skip the interview entirely. + +Set the target directory to `SCENARIO_DIR` and work through all 7 phases. +Once the interview completes and files are generated, continue with Load State +below. + +## Load State + +1. Read `scenario.yaml` — target metric, search space, experiment actions, failure playbook, timing. **Use scenario.yaml for all paths and parameters — never hardcode.** +2. Read `MEMORY.md` — best config, key lessons, session index. If it has prior findings, don't repeat experiments. +3. Read `case_studies/*.md` if they exist — past experiment reports on this scenario +4. Read today's `sessions/YYYY-MM-DD.md` if it exists (resume) +5. Read all skills in `/skills/` (experiment-playbook, metrics-guide, etc.) +6. Ensure `logs/` and `sessions/` directories exist + +### Resume: Check Active Runs + +If MEMORY.md "Active Runs" lists runs from a prior session, light-poll each with +the CLI: `uv run tangle sdk pipeline-runs status RUN_ID` (run + derived status +summary), and `uv run tangle sdk pipeline-runs graph-state EXECUTION_ID` for the +per-task graph state. Classify: all tasks terminal → Step 5, any RUNNING → +Step 4, any FAILED → Step 4 (debugger). This replaces Step 1-3 when resuming. + +## Bootstrap Pipeline + +### If no `pipeline.yaml` exists in `SCENARIO_DIR`: + +**Option A: User has pipeline source code (preferred)** +If the repo already has a pipeline YAML with `name:` or `digest:` component refs +(no inline `spec:` blocks), ask the user for its path and copy it: +```bash +cp $SCENARIO_DIR/pipeline.yaml +``` +This is preferred because `--hydrate` on submit (the default) will always resolve +the latest published component versions when the refs are `name:`/`digest:` only. + +**Option B: Export from baseline run** +If no source pipeline exists, export the root spec from the baseline run: +```bash +uv run tangle sdk pipeline-runs export BASELINE_RUN_ID --output $SCENARIO_DIR/pipeline.yaml +``` +`export` writes the root spec as-is. There is no `--dehydrate` flag: if the +exported YAML carries full inline `spec:` blocks, `--hydrate` on submit is a no-op +for those tasks (it has nothing to resolve). That is fine — hand-edit the exported +spec and submit it directly. If you want submit to pick up the latest published +versions, replace an inline `spec:` block with a `name:` or `digest:` ref before +submitting. + +### Then: +1. Parse pipeline.yaml to build task → source file mapping (see researcher agent for code discovery) +2. Record the baseline config and metrics under `$SCENARIO_DIR` (note the baseline `run_id` and any artifact `uri`s so you can re-reference them; see `references/data-sources.md` if your backend provides reusable data-source components) +3. Initialize `$SCENARIO_DIR/logs/events.jsonl` (create if it doesn't exist) + +## Gate — do NOT proceed to Step 1 until all pass: +- [ ] `uv run tangle --help` works (CLI reachable; see `references/setup.md` if not) +- [ ] `uv run tangle quickstart` ran successfully +- [ ] `SCENARIO_DIR` set to absolute path +- [ ] `scenario.yaml` read and understood +- [ ] `MEMORY.md` read +- [ ] Scenario skills loaded +- [ ] `references/tangle-tools.md` read (Submission Rules, light polling, CLI reference) +- [ ] `references/event-log.md` read (event types and schemas) +- [ ] `pipeline.yaml` exists in `SCENARIO_DIR` (either copied with `name:`/`digest:` refs, or exported and hand-edited) +- [ ] `logs/` and `sessions/` directories exist +- [ ] `step_transition` event logged +- [ ] **Reload + review**: re-read `SKILL.md`, `references/tangle-tools.md`, and `references/event-log.md`; agent confirms it remembers them before starting Step 1 diff --git a/skills/tangent/references/step-1-analyze.md b/skills/tangent/references/step-1-analyze.md new file mode 100644 index 0000000..a19475a --- /dev/null +++ b/skills/tangent/references/step-1-analyze.md @@ -0,0 +1,91 @@ +# Step 1: Analyze + +Choose the highest-ROI experiment direction based on evidence. + +## Round 1: Launch Researcher + +**Skip research if** `research-brief.md` already exists in `$SCENARIO_DIR` (from a prior +session or the scenario builder). Read the existing brief and proceed to "Every Round." + +Otherwise, **launch the researcher as a subagent using the Agent tool.** Do not just +read the file — you must actually invoke it as a foreground agent and wait for completion. +Skip only if user says "skip research" or `scenario.research.enabled` is explicitly `false`. + +Read `agents/researcher.md` and pass its full content as the agent prompt, with +this task context appended: +``` +--- +Task context: +Research the pipeline. +Scenario: +Baseline run_id: +Parent run_id: +Code paths: +Image roots: +Write brief to: /research-brief.md +Write priors to: /research-priors.txt +``` + +The `parent_run_id` is the round's *active run_id* for record keying — see the +researcher brief template. Round 1: no parent → key by `baseline_run_id`. + +**If the researcher agent fails or times out**, do NOT block the experiment loop. Log +the failure, then fall back to the experiment types in `scenario.experiment_actions` and +MEMORY.md priors. Research is high-value but not blocking. + +Then read `research-brief.md`. Your experiment choice MUST follow the top-ranked +research direction. Only fall back to generic parameter tuning if research +recommends it or all directions have been tried. + +## Round 2+ + +Read Step 5 analysis findings from the previous round (session log / MEMORY.md). +The Direction Proposal tells you what to try next based on the data. + +## Every Round + +1. Read MEMORY.md — best config, budget, lessons +2. Read `scenario.yaml` for available experiment types +3. Use free analysis actions before expensive experiments + +## Choosing Experiment Type + +Choose the TYPE, not just the parameter: + +| Experiment Type | Typical ROI | When to Use | +|----------------|-------------|-------------| +| Analysis (free) | Information | Always first | +| Feature selection | High | Dead features or feature dominance | +| Data actions | High | Noisy labels or train/eval misalignment | +| Parameter tuning | Medium-High | LR, capacity, regularization — first 2 rounds | +| Pipeline modifications | Medium | Pipeline topology is the bottleneck | + +## Record research brief to the learnings corpus + +After the researcher writes `research-brief.md`, record it in the local learnings +corpus. Key by the **active run_id** — for Round 1 that's `baseline_run_id`; for round 2+ +re-research that's the prior round's `best_run_id` (the parent run that motivated +the new research pass). + +```bash +# Round 1: active_run_id == baseline_run_id +# Round 2+: active_run_id == prior round's best_run_id +mkdir -p "$LEARNINGS_DIR/" +cp \ + "$SCENARIO_DIR/research-brief.md" \ + "$LEARNINGS_DIR//research-.md" +``` + +`LEARNINGS_DIR` defaults to `$SCENARIO_DIR/learnings/` and is env-overridable. If the +record fails, log a `learning_record_failed` event and keep going — local is the source +of truth. See `references/knowledge-corpus.md` for the corpus-directory layout and the +optional shared-tier (HuggingFace dataset repo) recipe. + +## Gate — do NOT proceed to Step 2 until all pass: +- [ ] Round 1: **`research-brief.md` exists at `$SCENARIO_DIR/research-brief.md`** (verify with Read — if missing, researcher did not run) +- [ ] Research brief recorded at `$LEARNINGS_DIR//research-.md` (Round 1: keyed by `baseline_run_id`; Round 2+: keyed by prior round's `best_run_id`) — or `learning_record_failed` event logged +- [ ] Round 2+: Step 5 Direction Proposal from previous round has been read +- [ ] MEMORY.md reviewed for budget and lessons +- [ ] Experiment direction chosen with **Phase A evidence** (not anchored on Phase B FYI context) +- [ ] `step_transition` event logged +- [ ] **Reload + review**: re-read this step file and `agents/researcher.md`; agent confirms it remembers them diff --git a/skills/tangent/references/step-2-hypothesize.md b/skills/tangent/references/step-2-hypothesize.md new file mode 100644 index 0000000..5a1a03f --- /dev/null +++ b/skills/tangent/references/step-2-hypothesize.md @@ -0,0 +1,42 @@ +# Step 2: Hypothesize + +## Round 1: Present the Research Plan + +The researcher already produced ranked directions with exact implementation steps. +**Present them directly — do not re-interpret or expand.** The research plan IS +the experiment plan. Your job is to present it clearly for user approval, not to +redesign it. + +``` +## Experiment Plan: + +**Target metric**: (baseline: , goal: by ) +**Budget**: runs, rounds, parallel +**Guard metrics**: + +### Research directions (from research-brief.md, in order) + + +### Round 1 experiment (= research direction #1) + +``` + +Do NOT proceed without explicit user approval. + +## Round 2+: Evidence-Driven Hypothesis + +State clearly: +- **What** you're changing and **why** (cite Step 5 Direction Proposal or MEMORY.md) +- **Expected outcome** and how you'll measure it +- **How many runs** — justify why not fewer + +**Design minimal experiments.** If evidence points to a clear fix, test it +directly with 1-2 runs. Don't sweep when you already know the answer. + +## Gate — do NOT proceed to Step 3 until all pass: +- [ ] Round 1: research directions presented verbatim from research-brief.md +- [ ] Round 1: user explicitly approved +- [ ] Round 2+: evidence cited, run count justified +- [ ] Budget impact calculated (won't exceed remaining budget) +- [ ] `step_transition` and `hypothesis` events logged +- [ ] **Reload + review**: re-read this step file and the current `research-brief.md`; agent confirms it remembers them diff --git a/skills/tangent/references/step-3-submit.md b/skills/tangent/references/step-3-submit.md new file mode 100644 index 0000000..3034d94 --- /dev/null +++ b/skills/tangent/references/step-3-submit.md @@ -0,0 +1,174 @@ +# Step 3: Submit + +**Never exceed budget.** Infra retries and analysis actions don't count against it. +Maximize concurrency — fill all `scenario.budget.max_parallel_runs` slots. + +| Budget | Runs/Round | Strategy | +|--------|-----------|----------| +| Tight (< 10) | 2-3 | Sequential refinement | +| Moderate (10-20) | 3-5 | Sweep primary dimension, refine | +| Generous (20-50) | 5-8 | Parallel sweeps | + +## Submission + +Refer to `scenario.experiment_actions` and the scenario's `experiment-playbook.md` +skill for the specific config changes per experiment type. + +Analysis actions don't consume budget — run locally, update session log. + +## Pipeline Submission + +**Follow the Submission Rules in `references/tangle-tools.md`** — hydrate is the +default, `submit` never waits, and run args go through `--arg` / `--args-json`. + +Run arguments are passed on the command line, not via a `-f config.yaml` file +(there is no `-f` flag). Use repeated `--arg K=V` for individual values, or +`--args-json ''` (or `--args-json @args.json`) for a structured payload. +Auto-loop bookkeeping rides along as generic `--annotation K=V` pairs: + +```bash +# experiment args + auto-loop annotations, assembled on the submit line +uv run tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ + --arg = \ + --annotation session=YYYY-MM-DD- \ + --annotation round= \ + --annotation type= \ + --annotation label= +``` + +If you want the bookkeeping written down before you submit, keep a plain +`$SCENARIO_DIR/run_config.yaml` note for yourself with the intended args and +annotation values — but the values still reach the run via `--arg` / +`--annotation`, not via a submit `-f` flag. + +### Pre-submit checks (run ALL of these BEFORE `pipeline-runs submit`) + +These checks must run *before* submission — once the pipeline is submitted, any +credential value or wrong reference is already persisted in the Tangle backend. +**Do not** reorder these after the submit command. + +```bash +# 1. Credential-body scan (uses grep -lE so values never echo to stdout; +# see also references/secrets.md and the "Do not cat/grep -n" warning below). +# Patterns are sized to match real tokens (>=20 chars of entropy) so doc +# comments mentioning the word "Bearer" or "sk-" don't false-positive. +LEAK_FILES=$(grep -lEi '(sk-[A-Za-z0-9_-]{20,}|xoxb-[0-9]+-[0-9]+-[A-Za-z0-9]+|EJ\[1:[A-Za-z0-9+/=]{20,}|Bearer [A-Za-z0-9_.-]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)' \ + $SCENARIO_DIR/pipeline.yaml 2>/dev/null) +if [ -n "$LEAK_FILES" ]; then + echo "ERROR: credential-shaped string detected in: $LEAK_FILES" + echo " Open these files in an editor (do NOT cat/grep them); route the value through dynamicData.secret." + exit 1 +fi + +# 2. Placeholder scan — catches REPLACE_ME_* / TODO / identifiers +# that the agent left for the human to fill in (per the autonomous-mode +# convention in references/secrets.md). These must be resolved before submit +# or the pipeline will fail at runtime when the component tries to look up +# a non-existent secret. +PLACEHOLDERS=$(grep -lE '(REPLACE_ME_|/dev/null) +if [ -n "$PLACEHOLDERS" ]; then + echo "ERROR: unresolved placeholders in: $PLACEHOLDERS" + echo " Ask the human for the correct identifier (secret name, etc.) before submitting." + exit 1 +fi + +# 3. Tangle-secret existence check — every `dynamicData.secret.name` referenced +# in the pipeline must exist under the authenticating identity. A typoed or +# not-yet-created name passes step 1 (it's not a credential body) but fails +# at runtime, which wastes budget and confuses the user. +# Run under `uv run` so the project env (incl. PyYAML) is available — bare +# python3 may not have PyYAML, per the bundle's uv-run rule (OSS-CONVENTIONS §1). +uv run python3 - <<'PY' "$SCENARIO_DIR/pipeline.yaml" +import sys, yaml, subprocess, json +path = sys.argv[1] +spec = yaml.safe_load(open(path)) +# Collect every dynamicData.secret.name reference, anywhere in the doc. +refs = set() +def walk(o): + if isinstance(o, dict): + s = o.get("dynamicData", {}).get("secret", {}).get("name") if isinstance(o.get("dynamicData"), dict) else None + if isinstance(s, str): refs.add(s) + for v in o.values(): walk(v) + elif isinstance(o, list): + for v in o: walk(v) +walk(spec) +if not refs: + sys.exit(0) +# stderr=DEVNULL drops auth warnings; --log-type none drops info logs. +out = subprocess.check_output( + ["uv","run","tangle","sdk","secrets","list","--log-type","none"], + text=True, stderr=subprocess.DEVNULL, +) +existing = {s["secret_name"] for s in json.loads(out).get("secrets", [])} +missing = sorted(refs - existing) +if missing: + sys.stderr.write( + "ERROR: pipeline references Tangle secrets that don't exist under the " + "authenticating identity: " + ", ".join(missing) + "\n" + " Ask the human to create them via: uv run tangle sdk secrets create --from-env \n" + " (see references/secrets.md § 'If the secret is missing, ask the human to create it').\n" + ) + sys.exit(1) +PY +if [ $? -ne 0 ]; then exit 1; fi +``` + +**Do not `cat`, `grep -n`, or otherwise echo the matching files to stdout to +"see what was matched"** — that re-leaks the value into your terminal, +agent transcript, and (if you re-run from shell history) shell history. +Open the suspect file in an editor instead and use the editor's search. + +These three checks (credential-body + placeholders + secret-name existence) are +the complete pre-submit gate — every one must exit cleanly before the +`pipeline-runs submit` command below runs. If any of them prints `ERROR:`, +stop, surface the failure to the human, and do not submit. + +### Submit (only after every pre-submit check above exited cleanly) + +```bash +uv run tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ + --arg = \ + --annotation session=YYYY-MM-DD- \ + --annotation round= \ + --annotation type= \ + --annotation label= +``` + +Hydration is the default, so component versions are resolved as part of submit. +`submit` returns as soon as the run is created — it does **not** wait. To block +on completion, follow up with `uv run tangle sdk pipeline-runs wait RUN_ID`. + +### If you modified component source code: +1. Rebuild from your Python source, pointing at the image you built and pushed + yourself: `uv run tangle sdk components generate from-python source.py --image --output component.yaml` +2. Update ref in pipeline YAML: swap `digest: ...` with `url: file://` +3. Submit with the command above + +See `agents/builder.md` for the full workflow. + +## Post-Submission + +`uv run tangle sdk pipeline-runs submit` returns a **`run_id`** for each submitted +pipeline. Treat `run_id` as a first-class session concept: + +1. Log to `sessions/YYYY-MM-DD.md` — record the `run_id` in a `## Run Log` + section with timestamp, round, label, config diff. Order is chronological. +2. **Write to MEMORY.md "Active Runs" immediately** — `run_id`, run link + (`/runs/`, or inspect via + `uv run tangle sdk pipeline-runs details RUN_ID`), label, config summary, + timestamp. Survives session interruptions. +3. The `run_id` is what keys learnings records in Step 7 + (`learning-.json` under `$LEARNINGS_DIR//`), so make sure + it's recorded verbatim. + +## Gate — do NOT proceed to Step 4 until all pass: +- [ ] **All three pre-submit checks (credential-body, placeholder, secret-name existence) exited cleanly BEFORE `pipeline-runs submit` ran** +- [ ] If source code was modified: component rebuilt and pipeline ref updated +- [ ] All runs submitted successfully (run IDs received) +- [ ] Each run annotated with session, round, type, label +- [ ] MEMORY.md "Active Runs" updated with every `run_id` +- [ ] Session log `Run Log` section updated with every `run_id` +- [ ] Budget check: total runs submitted ≤ remaining budget +- [ ] `step_transition` and `run_submit` events logged +- [ ] **Reload + review**: re-read this step file and `references/tangle-tools.md`; agent confirms it remembers them diff --git a/skills/tangent/references/step-4-monitor.md b/skills/tangent/references/step-4-monitor.md new file mode 100644 index 0000000..6d8502e --- /dev/null +++ b/skills/tangent/references/step-4-monitor.md @@ -0,0 +1,123 @@ +# Step 4: Monitor + +Do NOT exit until all runs are DONE or permanently failed. Keep slots filled. + +## Loop + +``` +LOOP: + 1. Light-poll all runs (graph-state CLI, cheap status summary) + 2. Inspect completed/failed runs (pipeline-runs details --include-execution-state) + 3. Process completed runs → queue for Step 5 + 4. Process failed runs → launch debugger agent + 5. Backfill open slots → go to Step 2 for new experiments + 6. Wait → dispatch or sleep, loop +``` + +## Checking Run Status (Light Polling) + +**Use the purpose-built status/graph-state CLI for status checks — NOT +`pipeline-runs wait` or `pipeline-runs details`.** `pipeline-runs status` returns a +run plus a derived status summary, and `pipeline-runs graph-state` returns the +per-task graph execution state — both far cheaper than the full execution tree. +`pipeline-runs details --include-execution-state` returns the entire execution tree +(very large). Only use the heavy call when you need to debug or extract execution_ids. + +Prefer the CLI over hand-rolling a Python poll loop (the `TangleApiClient` verified +surface is `pipeline_runs_get(run_id)` and `find_existing_components(...)`; for status +and graph state, the CLI commands below are the supported path). + +Light status for a single run (run + derived status summary): +```bash +uv run tangle sdk pipeline-runs status RUN_ID +``` + +Graph execution state (per-task status counts; takes an EXECUTION_ID): +```bash +uv run tangle sdk pipeline-runs graph-state EXECUTION_ID +``` + +For multiple runs, call `status` once per run id: +```bash +for rid in RUN_1 RUN_2 RUN_3; do + echo "$rid:" + uv run tangle sdk pipeline-runs status "$rid" +done +``` + +`status` surfaces the run's execution id; pass that to `graph-state` when you need the +per-task breakdown for a specific run. + +Mark runs exceeding 2x `scenario.timing.total_seconds` as STUCK and replace. + +## Post-Completion Inspection + +When a run completes (SUCCEEDED or FAILED), immediately run: +```bash +uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state +``` + +This returns the execution tree with per-component status. Check for: +1. Any component in FAILED or SYSTEM_ERROR state (the overall run may show SUCCEEDED + if the failed component was optional or non-blocking) +2. Components with unexpectedly short execution times (may indicate silent failures) +3. The training/tuning component specifically — note its execution_id for log fetching + in Step 5 + +If any component failed unexpectedly, launch the debugger subagent even if the +overall pipeline status is SUCCEEDED. + +Record the execution_id of key components (training, evaluation) in the session log +alongside the run_id. Step 5 will need these for detailed analysis. + +## Failed Runs + +**Launch the debugger as a subagent using the Agent tool.** Read `agents/debugger.md` +and pass its full content as the agent prompt, with this task context appended: +``` +--- +Task context: +Run ID: +Failure playbook: +Pipeline task mapping: +Write snapshot to: /logs/failures/.md +Return one-line: ": " +``` + +Act on the diagnosis: + +| Failure Type | Action | Budget Impact | +|-------------|--------|---------------| +| **PERMISSION** | Fix per diagnosis, resubmit | No cost | +| **INFRA** | Retry if transient, fix if persistent | No cost | +| **CONFIG** | Fix per diagnosis, resubmit | No cost | +| **TRAINING** | Record as result (failure IS data) | Already counted | +| **EVAL** | Fix per diagnosis, resubmit | No cost | +| **UNKNOWN** | Escalate to user | No cost | + +## Cancelling a Run + +Only cancel when the user asks or a run is blocking resources. +```bash +uv run tangle sdk pipeline-runs cancel RUN_ID +``` + +## Waiting + +Use `dispatch` for non-blocking wait (if available), or sleep between light polls: +``` +dispatch({ command: "uv run tangle sdk pipeline-runs wait --max-wait --poll-interval 10 --exit-on-first-failure" }) +``` +Interval: 180s (short pipelines), 600s (medium), 900s (long). `wait` defaults to +`--max-wait 600` and `--poll-interval 10` if you omit them. +On wake, light-poll ALL active runs (`pipeline-runs status`), not just the awaited one. + +## Gate — do NOT proceed to Step 5 until all pass: +- [ ] All runs resolved (SUCCEEDED, permanently FAILED, or STUCK-replaced) +- [ ] No RUNNING runs remain +- [ ] Each completed run inspected with `pipeline-runs details --include-execution-state` +- [ ] Key component execution_ids recorded in session log +- [ ] Each failed run has a debugger snapshot in `logs/failures/` +- [ ] Each failed run has a `run_failed` event logged +- [ ] Backfill exhausted (no open slots with pending experiments and budget) +- [ ] **Reload + review**: re-read this step file and `agents/debugger.md`; agent confirms it remembers them diff --git a/skills/tangent/references/step-5-evaluate.md b/skills/tangent/references/step-5-evaluate.md new file mode 100644 index 0000000..6fdd180 --- /dev/null +++ b/skills/tangent/references/step-5-evaluate.md @@ -0,0 +1,53 @@ +# Step 5: Evaluate + +Two parts: collect metrics, then analyze. + +## Part 1: Collect Metrics + +For each completed run: +1. Get artifact metadata for the eval task with `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {"": [""]}}'`. This returns metadata records (`{id, uri, size, hash}`) — it does **not** download bytes. Read the `uri` scheme-agnostically (under the OSS backend it is typically `hf://…`); never assume a particular storage scheme. To read the metric JSON contents, follow the signed-URL fetch recipe in [`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5: `artifacts get` → `tangle api artifacts signed-artifact-url` → fetch with `curl -L` (or `huggingface_hub` for `hf://` URIs). +2. Extract target metric (`scenario.metrics.target.path`) and common metrics +3. Check guard metrics (`scenario.metrics.guards`) +4. Remove from MEMORY.md "Active Runs" + +Present results sorted by target metric. Include all common metrics. + +**Statistical significance** (assumes fixed eval set): +- < 0.3%: likely noise. > 1.0%: almost certainly real. + +### Optional: Fetch Training Logs + +For the best run and any anomalous results, fetch container logs from the training component: +```bash +uv run tangle sdk pipeline-runs logs EXECUTION_ID +``` +Use the execution_id recorded in Step 4 (logs are keyed by EXECUTION_ID, not run id). Look for: +- Training convergence (loss progression) +- Early stopping triggers +- Warning messages about data quality +- OOM or resource warnings + +For OOM, eviction, scheduling, or "pod not found" events, the Tangle backend does not store these — consult your launcher's runtime (see [`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §7 and [`references/event-log.md`](event-log.md)). + +## Part 2: ML-Driven Analysis + +For the best run + any unexpected results, analyze deeper: +1. **Error Analysis** — fetch predictions (via the §5 signed-URL recipe), compare vs baseline per-example, identify biggest movers +2. **SHAP Diff** — compare feature importances between runs +3. **Metric Decomposition** — per-segment breakdown, check guards per-segment +4. **Direction Proposal** — propose next experiments based on findings + +The Direction Proposal feeds into Step 1 of the next round. + +Promote significant findings to MEMORY.md. + +## Gate — do NOT proceed to Step 6 until all pass: +- [ ] Metrics fetched and extracted for every completed `run_id` +- [ ] Guard metrics checked for every run +- [ ] Results comparison table printed to user (keyed by `run_id`) +- [ ] MEMORY.md "Active Runs" cleared for all completed `run_id`s +- [ ] Session log `Run Log` updated with outcome per `run_id` +- [ ] ML analysis performed on best run (error, SHAP, segments) +- [ ] Direction Proposal written (what to try next round) +- [ ] `run_complete`, `analysis`, and `step_transition` events logged +- [ ] **Reload + review**: re-read this step file and [`references/event-log.md`](event-log.md); agent confirms it remembers them diff --git a/skills/tangent/references/step-6-synthesize.md b/skills/tangent/references/step-6-synthesize.md new file mode 100644 index 0000000..801949d --- /dev/null +++ b/skills/tangent/references/step-6-synthesize.md @@ -0,0 +1,56 @@ +# Step 6: Synthesize + +## Update Memory + +- **Session log** (`sessions/YYYY-MM-DD.md`): full metrics, run IDs, analysis +- **MEMORY.md**: update Best Known Config, add one-line lesson, verify Active Runs empty +- **Audit trail** (`logs/audit.yaml`): append round entry with rationale and outcome + +## Preserve artifacts worth reusing + +If this round produced an artifact future rounds or other scenarios would +benefit from — best-so-far model checkpoint, a curated eval/annotation +dataset, a frozen feature snapshot — record its `run_id` and the artifact +`uri` (read scheme-agnostically from `artifacts get`; see +[`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5) in `MEMORY.md` so a later +round can re-reference it directly. Be mindful of what you persist into a +shared corpus: do not record anything containing PII, sensitive data, +contractually restricted datasets, embargoed model weights, or secrets. + +If your backend provides dedicated data-source components, you can instead +wire the artifact into the next submit so it gets an opaque +`data_source_id` you record in `MEMORY.md`; see +[`data-sources.md`](data-sources.md) for that conditional pattern and its +safety checklist. Preserving an artifact this way is optional — skip it for +routine rounds. + +## Report (MANDATORY — do NOT skip, do NOT defer) + +**Launch the reporter as a subagent.** Do not just read the file — you must +actually invoke it. Pass the full content of `agents/reporter.md` as the agent +prompt, with this task context appended: + +``` +--- +Task context: +Scenario: +Read: /logs/audit.yaml, events.jsonl, MEMORY.md, sessions/.md, scenario.yaml +Baseline run: +Round's best run_id: # use the best-performing run of this round +Write to: /logs/report-.md +``` + +`` is the round's representative run_id. Single-run rounds: it's the +only run. Multi-run rounds: the run with the best target metric. The same +`` is used by Step 7 to key the reviewer output and the +`learning-.json` record. + +## Gate — do NOT proceed to Step 7 until all pass: +- [ ] Session log updated with full metrics and analysis +- [ ] MEMORY.md updated (best config, lessons, Active Runs empty) +- [ ] `audit.yaml` round entry appended +- [ ] **`report-.md` exists at `$SCENARIO_DIR/logs/`** (verify with Read — if it doesn't exist, the reporter did not run; the per-run filename means earlier rounds are preserved) +- [ ] `round_end` event logged +- [ ] **Reload + review**: re-read this step file and `agents/reporter.md`; agent confirms it remembers them + +Print this checklist to the user with checkmarks. diff --git a/skills/tangent/references/step-7-decide.md b/skills/tangent/references/step-7-decide.md new file mode 100644 index 0000000..99fbd16 --- /dev/null +++ b/skills/tangent/references/step-7-decide.md @@ -0,0 +1,79 @@ +# Step 7: Decide + +## Review Gate + +**Launch the reviewer as a subagent using the Agent tool.** Read `agents/reviewer.md` +and pass its full content as the agent prompt, with this task context appended: +``` +--- +Task context: +Scenario: +Best run: # best-performing run of this round +Baseline run: +Scenario dir: +Report path: /logs/report-.md +Write review to: /logs/review-.md +Return one-line: ": " +``` + +The same `` keys the report (Step 6), this review, and the final +`learning-.json` record below. + +Act on the verdict: +- **APPROVE** — proceed with convergence check below +- **CONCERNS** — address the issues, then re-evaluate +- **BLOCK** — fix blocking issues first (go to Step 1) + +## Convergence + +Check `scenario.budget.convergence`: min_improvement met? Budget exhausted? +patience_rounds without improvement? + +Before stopping on a plateau: have you tried feature selection, data actions, +analysis actions, or pipeline modifications? + +**If done**: copy `logs/report-.md` to +`case_studies/-.md`, update status to final outcome +(SUCCESS / MARGINAL / NO_IMPROVEMENT / REGRESSION). + +**If not done**: go to Step 1. + +## Record final learning to the corpus + +After every round (regardless of outcome), write a structured `learning.json` +locally and record it to the learnings corpus. Negative results are signal too — +do not skip recording on regression or failure. + +**Use `` (the round's representative run) as the file key.** Multi-run +rounds key by the best-performing run; single-run rounds key by that run. + +Write `$SCENARIO_DIR/logs/learning-.json` with the schema documented in +[`references/knowledge-corpus.md`](knowledge-corpus.md) (scenario, run_id, +hypothesis, config_diff, primary_metric, all_metrics, outcome, lesson, +next_direction). For multi-run rounds, include sibling run_ids and their metrics in +the `all_metrics` block so the corpus retains the full round. + +The corpus is a local directory by default (`LEARNINGS_DIR`, default +`$SCENARIO_DIR/learnings/`), with an optional shared HuggingFace dataset tier; see +[`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §6 and +[`references/knowledge-corpus.md`](knowledge-corpus.md) for the full recipe. Record +the learning with a plain copy into the corpus directory: + +```bash +mkdir -p "$LEARNINGS_DIR/" +cp "$SCENARIO_DIR/logs/learning-.json" \ + "$LEARNINGS_DIR//learning-.json" +``` + +If recording fails (e.g. the corpus directory is unwritable), log a +`learning_record_failed` event and keep going — the local copy under +`$SCENARIO_DIR/logs/` is the source of truth and a future session can retry. + +## Gate — do NOT finalize until all pass: +- [ ] **`review-.md` exists at `$SCENARIO_DIR/logs/`** (verify with Read — per-round file; if missing, reviewer did not run) +- [ ] Verdict is APPROVE (not CONCERNS or BLOCK) +- [ ] Convergence criteria evaluated against scenario.budget +- [ ] `learning-.json` written under `$SCENARIO_DIR/logs/` and recorded to `$LEARNINGS_DIR//` (or `learning_record_failed` event logged) +- [ ] If converged: `logs/report-.md` copied to `case_studies/`, status updated +- [ ] `report_generated` event logged (if converged) +- [ ] **Reload + review**: re-read this step file and [`references/knowledge-corpus.md`](knowledge-corpus.md); agent confirms it remembers them before looping back to Step 1 (or finalizing) diff --git a/skills/tangent/references/tangle-tools.md b/skills/tangent/references/tangle-tools.md new file mode 100644 index 0000000..442f626 --- /dev/null +++ b/skills/tangent/references/tangle-tools.md @@ -0,0 +1,401 @@ +# Tangle CLI Reference + +This is the in-skill mirror of the CLI surface defined in +[`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md). When a command, flag, env var, or +recipe is ambiguous, that document is the source of truth — this file does not +re-derive it. + +**Always use the `tangle` CLI commands via Bash.** The skills drive the OSS core +(`tangle sdk …` and `tangle api …`) and never any wrapper/hook layer. + +**Do not rely on a static list of commands.** The `tangle-cli` repo is updated frequently +with new commands and flags — discover the current surface with `--help` and +`quickstart` (below) rather than memorizing it. + +## Install / Invoke + +Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo: + +```bash +uv run tangle quickstart +uv run tangle --help +uv run tangle sdk --help +uv run tangle api --help +``` + +The `[native]` extra enables the static API-backed commands and the handwritten +`TangleApiClient` (see [Programmatic client](#programmatic-client-python)). In the `tangle-cli` workspace `uv` installs the workspace `tangle-api` package automatically for +dev/tests. + +> Once `tangle-cli` is promoted to the public OSS package, you will be able to +> `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, +> use `uv run tangle …` from a checkout of the repo. + +## Discover Available Commands + +```bash +uv run tangle quickstart +``` + +This prints static onboarding text. Use it at the start of every session to learn +what's available, then drill in with `--help`. + +## Get Help on a Specific Command + +Help is standard cyclopts `--help` — there is no `--help-extended` / +`--help-full`: + +```bash +uv run tangle sdk --help +uv run tangle api --help +``` + +For broader docs, point at `tangle sdk --help`, the curated standard +library (`uv run tangle sdk published-components library`), and the public OSS +docs at `github.com/TangleML/website/tree/master/docs`. + +## Running Commands + +The CLI splits into two families: `tangle api …` (auto-generated OpenAPI +wrappers) and `tangle sdk …` (hand-written SDK / local / compound commands). +Most workflow commands live under `tangle sdk`: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml --arg shop=acme --annotation session=2026-06-23-ranking +uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state +``` + +Auth flags (see [Auth & environment](#auth--environment)) attach to any +API-backed command. + +## Submitting a Run (applies to ALL pipelines, ALL agents) + +The OSS `submit` reality is simpler than the internal one — there is no +dehydration step, no wait flag, and no source-attribution annotation: + +### 1. Hydrate is the default + +`submit` resolves component versions by default. There is **no** dehydration +check and **no** "dehydrate first" guard. If you want to submit a spec exactly as +written (no version resolution), pass `--no-hydrate`. Hydration is a no-op on a +spec that is already fully inline. + +### 2. `submit` never waits + +There is **no** `--no-wait` flag — `submit` returns as soon as the run is +created. To block until completion, use a separate `wait` (see +[Waiting / polling](#waiting--polling)). + +### 3. Run arguments via `--arg` / `--args-json` + +There is **no** `-f config.yaml`. Pass pipeline run arguments with repeatable +`--arg K=V` flags, or `--args-json ''` (or `--args-json @file.json` when a +file is preferred): + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg model=baseline --arg epochs=3 + +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --args-json '{"model": "baseline", "epochs": 3}' +``` + +`--config` carries CLI-option defaults (base-url / auth / log-type), **not** +pipeline run args. + +### 4. Annotations are generic and optional + +There is no mandatory annotation. Use portable annotations to make runs +searchable; all are optional: + +| Key | Value | Typical use | +|-----|-------|-------------| +| `session` | `YYYY-MM-DD-scenario` | Auto-loop grouping | +| `round` | `"1"`, `"2"`, … | Auto-loop iteration | +| `type` | experiment type | Auto-loop | +| `label` | short description | Auto-loop | +| `source` | `tangle-cli` | Optional provenance marker (only if you want it) | + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg model=baseline \ + --annotation session=2026-06-23-ranking \ + --annotation round=1 \ + --annotation type=baseline \ + --annotation label="initial ranking run" +``` + +Or set after submission: + +```bash +uv run tangle sdk pipeline-runs annotations set RUN_ID session 2026-06-23-ranking +uv run tangle sdk pipeline-runs annotations list RUN_ID +uv run tangle sdk pipeline-runs annotations delete RUN_ID round +``` + +### Preview without submitting + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run +``` + +`--dry-run` prints the submit body and creates no run. + +### Canonical submit command + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg K=V --annotation session=YYYY-MM-DD-scenario +``` + +## Validating & Editing Pipelines + +Local pipeline operations live under `pipelines` (NOT `pipeline-runs`): + +```bash +uv run tangle sdk pipelines validate pipeline.yaml +uv run tangle sdk pipelines layout pipeline.yaml [--recursive] [-o out.yaml] +uv run tangle sdk pipelines hydrate template.yaml -o out.yaml [--var K=V] +uv run tangle sdk pipelines diagram pipeline.yaml # Mermaid (no GUI viewer) +``` + +There is no `dehydrate` command or `--dehydrate` flag in OSS. To iterate on an +existing run, `export` the root spec as-is, hand-edit, then `submit` (hydrate is +the default) — see [`references/iterating-on-runs.md`](iterating-on-runs.md). + +## Checking Run Status (Light vs Heavy) + +**Light** — use for polling. Prefer the purpose-built CLI commands over a +hand-rolled Python loop: + +```bash +uv run tangle sdk pipeline-runs status RUN_ID # run + derived status summary +uv run tangle sdk pipeline-runs graph-state EXECUTION_ID # graph execution state +``` + +**Heavy** — use only after completion, for debugging or extracting +`execution_id`s: + +```bash +uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state +uv run tangle sdk pipeline-runs details RUN_ID --include-implementations +uv run tangle sdk pipeline-runs details RUN_ID --include-annotations +uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID +``` + +## Waiting / Polling + +To block until a run completes (bounded): + +```bash +uv run tangle sdk pipeline-runs wait RUN_ID \ + --max-wait 600 --poll-interval 10 [--exit-on-first-failure] +``` + +Defaults: `--max-wait` 600s, `--poll-interval` 10s. + +## Searching Runs + +```bash +uv run tangle sdk pipeline-runs search --name NAME \ + [--created-by USER] [--annotation K=V] \ + [--start-date DATE] [--end-date DATE] [--limit N] [--query JSON] [QUERY] +``` + +## Exporting a Run + +```bash +uv run tangle sdk pipeline-runs export RUN_ID --output out.yaml # omit --output to print to stdout +``` + +Exports the root spec as-is; there is no `--dehydrate`. + +## Fetching Container Logs + +For application logs (stack traces, code errors), keyed by **EXECUTION_ID**: + +```bash +uv run tangle sdk pipeline-runs logs EXECUTION_ID +``` + +This backend-native container log surface is the **only** log surface the Tangle +backend stores. + +### System events (OOM, eviction, scheduling, "pod not found") + +The Tangle backend does **not** store Kubernetes/system events — these are +**launcher-specific**. Container logs answer "what did the code print?"; +system events answer "why did the pod disappear?". Consult your launcher's +runtime: + +| Launcher | System-event source | +|---|---| +| `kubernetes`, `google_kubernetes` | `kubectl get events`, `kubectl describe pod ` | +| `local_docker` | `docker logs `, `docker inspect ` | +| `skypilot` | the cluster console / `sky logs` | +| `huggingface` | the Space's logs in the HF UI | + +This is a genuine degradation vs a unified system-event search: there is no +single place to search infra failures across launchers. For infra-failure +diagnosis, lean on container logs plus your launcher's native events — see +[`agents/debugger.md`](../agents/debugger.md). + +## Artifacts + +`tangle sdk artifacts get` is **metadata-only**. It returns records of the form +`{id, uri, size, hash}` (and a `count`); the `uri` is backend-agnostic — read the +`uri` field, do not assume a scheme. Under the OSS backend, URIs look like +`hf://datasets//@/`. + +```bash +uv run tangle sdk artifacts get RUN_ID -q '{"artifact_ids":[""]}' +``` + +`-q`/`--query` is a JSON string with optional keys `tasks`, `components`, +`executions`, `artifact_ids`. + +There is **no** `artifacts download` / `-o`-to-disk command. To fetch bytes (the +standard recipe — see [`OSS-CONVENTIONS.md` §5](../OSS-CONVENTIONS.md)): + +1. Get metadata and read the `uri` (above). +2. Ask the backend for a signed URL: + ```bash + uv run tangle api artifacts signed-artifact-url --id + ``` +3. Fetch with a generic client — `curl -L "" -o ./out`, or for + `hf://` URIs `huggingface_hub` (`hf_hub_download` / `snapshot_download`). + +Metadata-only is sufficient for many checks (existence / size / hash). Only fetch +bytes where per-example or metric-content analysis genuinely requires it. + +## Components + +Local authoring lives under `components`; the registry lives under +`published-components`. + +```bash +# Generate a component from a Python source file (user-built image) +uv run tangle sdk components generate from-python source.py \ + [--function NAME] [--image REG/IMG:TAG] [--output OUT] [--name NAME] \ + [--mode inline|bundle] [--dependencies-from REQ] [--strip-code] [--resolve-root DIR] + +# Bump a local component's version +uv run tangle sdk components bump-version component.yaml [--set-version V] [--update-timestamp] +``` + +There is no image build/push in the CLI. Build and push the image yourself (your +own docker/podman), then pass `--image ` to +`generate from-python`. `set-container-image` is a stub and must not be used. See +[`agents/builder.md`](../agents/builder.md). + +```bash +# Publish to the registry +uv run tangle sdk published-components publish component.yaml \ + [--image …] [--name …] [--description …] [--annotations JSON] [--dry-run] [--published-by …] + +# Inspect (exactly one of NAME or --digest) +uv run tangle sdk published-components inspect --name NAME --full-spec +uv run tangle sdk published-components inspect --digest DIGEST + +# Search — keyword/name/digest only +uv run tangle sdk published-components search NAME \ + [--digest D] [--published-by U] [--include-deprecated] + +# Deprecate +uv run tangle sdk published-components deprecate DIGEST [--superseded-by DIGEST] + +# Curated standard library +uv run tangle sdk published-components library +``` + +To publish many components at once, pass a `--config` YAML/JSON list (or +`_defaults` + `configs`) to the same `published-components publish` — it +aggregates and exits nonzero on any error. + +Component search is keyword/name/digest only (no semantic / fuzzy / regex / +schema variants). The remote-component-library feature is **off by default** in +OSS, so treat component discovery as **optional** and tolerate empty results on a +fresh install — never a hard prerequisite of a workflow step. + +## Secrets + +```bash +uv run tangle sdk secrets list +uv run tangle sdk secrets create NAME --from-env ENVVAR [-d DESCRIPTION] [--expires-at WHEN] +uv run tangle sdk secrets update NAME --from-env ENVVAR +uv run tangle sdk secrets delete NAME [--force] +``` + +Prefer `--from-env`/`-e ENVVAR` over `--value`/`-v` to avoid leaking the value +into shell history. `delete` prompts unless `--force`. Secrets belong to the +authenticating identity — see [`references/secrets.md`](secrets.md). + +## Cancelling a Run + +```bash +uv run tangle sdk pipeline-runs cancel RUN_ID +``` + +## Programmatic client (Python) + +Prefer the CLI for status/polling. When you genuinely need Python, the stable +public wrapper is `tangle_cli.client.TangleApiClient`: + +```python +from tangle_cli.client import TangleApiClient + +# Defaults to the local dev backend; pass base_url + auth for a remote backend. +client = TangleApiClient( + "http://localhost:8000", + token=None, # Bearer token shorthand + auth_header=None, # full Authorization value, e.g. "Bearer …" / "Basic …" + header=None, # one "Name: value" string or a list of them + headers=None, # mapping form, if you prefer a dict +) +run = client.pipeline_runs_get("run-id") +existing = client.find_existing_components( + ["component-name"], + published_by_substring="alice@example.com", +) +``` + +- Constructor: `TangleApiClient(base_url, *, token=, auth_header=, header=, headers=, …)`. + A bare `TangleApiClient()` only works against the default localhost backend. +- Importing it requires the native bindings — install the `[native]` extra (or + provide a local `tangle_api.generated` package) before + `from tangle_cli.client import …`. The top-level `import tangle_cli` is + intentionally native-free. +- The verified surface is `client.pipeline_runs_get(run_id)` and the + `find_existing_components(...)` helper. For status/graph state, prefer the CLI: + `tangle sdk pipeline-runs status RUN_ID` and + `tangle sdk pipeline-runs graph-state EXECUTION_ID`. + +## Auth & environment + +OSS auth is explicit and layered: explicit CLI option > `--config` file value > +environment default. There is no `auth` command group. + +| CLI option | Env var(s) | Purpose | +|---|---|---| +| `--base-url` | `TANGLE_API_URL` | API origin. Defaults to the local dev 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 (single object, a list, or `_defaults` + `configs`). | +| — | `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics. | + +Run links: there is no hosted dashboard URL to assume — use `/runs/` +or inspect via `uv run tangle sdk pipeline-runs details RUN_ID`. + +Example for a protected backend: + +```bash +uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --base-url https://api.example \ + --auth-header 'Bearer …' \ + -H 'X-Gateway-Auth: …' \ + --log-type console +``` + +For first-time credential setup, see [`agents/auth-wizard.md`](../agents/auth-wizard.md) +and [`references/setup.md`](setup.md). diff --git a/skills/tangent/references/uploading-artifacts.md b/skills/tangent/references/uploading-artifacts.md new file mode 100644 index 0000000..d4896f1 --- /dev/null +++ b/skills/tangent/references/uploading-artifacts.md @@ -0,0 +1,229 @@ +# Ingesting staged artifacts into Tangle pipelines + +The canonical way to bring a locally-staged artifact (data file, model checkpoint, +config bundle, reference dataset, …) into a Tangle pipeline is: + +1. **Stage** the artifact somewhere your pipeline can reach it at run time — an + object store, an HF dataset/model repo, or any URI your backend's ingest + component understands. (Where bytes live is backend-specific; see + [Staging the bytes](#staging-the-bytes).) +2. **Add or update** a task in the pipeline that uses your backend's **ingest + component** to pull the artifact at run time and emit it as a `Data` output. +3. **Wire** the `Data` output into the downstream task that needs the artifact. + +An ingest component is the transparent, reproducible ingest path. Do not write a +custom shell-out component or bake the artifact into a container image just to get +it into a pipeline — that hides the dependency from the graph. + +> **Backend-dependent.** The OSS `tangle` core does **not** ship a built-in ingest +> component. Whether one is available — and what its name, inputs, and outputs are +> — depends on what components your backend has published. Discover what's +> available with `uv run tangle sdk published-components library` and +> `uv run tangle sdk published-components search ` (search is **optional** and +> may return empty on a fresh OSS install — see +> [`OSS-CONVENTIONS.md` §10 D11](../OSS-CONVENTIONS.md)). If your backend provides +> no ingest component at all, fall back to a Python component that reads the URI +> itself (see [Gotchas](#gotchas)). + +## The ingest component shape + +A typical ingest component looks like this: + +| Field | Value | +|---|---| +| Name | backend-specific (e.g. `Download artifact`, `Fetch URI`) | +| Input | a URI or path (single blob or directory; **trailing slash means directory** — see [Gotchas](#gotchas)) | +| Output | `Data` (file path or directory path the next task can consume) | + +Inspect whatever your backend provides before wiring it in: + +```bash +uv run tangle sdk published-components inspect --name "" --full-spec +# or, if you already have the digest: +uv run tangle sdk published-components inspect --digest --full-spec +``` + +## Recipe A — Add a new ingest task to an existing pipeline + +When the pipeline does not already have an ingest task and you need to introduce +one — e.g., a new feature dataset, a model checkpoint to fine-tune from, an +external evaluation set. + +1. **Export the pipeline's root spec** so you can edit it locally: + ```bash + uv run tangle sdk pipeline-runs export --output /tmp/pipeline.yaml + ``` + `export` writes the root spec as-is (there is no `--dehydrate`; omit `--output` to + print to stdout). To iterate on the run, hand-edit this file and `submit` it — + hydration is the default and resolves component versions for you. + +2. **Add the ingest task** to the top-level graph or the appropriate subgraph: + ```yaml + tasks: + ingestInputData: + componentRef: + name: "" + # Optional: pin by digest after first use for reproducibility. + arguments: + uri: + # Hard-code for a one-shot run: + constant: "://path/to/artifact/" + # Or, to make the path a run-time parameter: + # graphInput: {inputName: input_artifact_uri} + ``` + +3. **Wire the `Data` output** into the consumer task by replacing whatever + argument used to supply the artifact: + ```yaml + tasks: + myTrainer: + arguments: + training_data: + taskOutput: + taskId: ingestInputData + outputName: Data + ``` + +4. **Validate, then submit** (hydrate is the default; `submit` never waits — block + afterward with `pipeline-runs wait` if you need to): + ```bash + uv run tangle sdk pipelines validate /tmp/pipeline.yaml + uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + --arg input_artifact_uri="://path/to/artifact/" + uv run tangle sdk pipeline-runs wait --max-wait 600 + ``` + + (Pass `--arg` only if you wired the URI as a `graphInput` in step 2; for a + hard-coded `constant` you don't need it.) + +## Recipe B — Swap an existing ingest URI + +When the pipeline already has an ingest task and you only need to point it at a +new artifact. + +1. Find the task in the exported YAML — search for the ingest component (by + `name:` or `digest:`) or for its `arguments.:`. +2. Replace the value: + ```yaml + # Before + arguments: + uri: + constant: "://old/path/" + # After + arguments: + uri: + constant: "://new/path/" + ``` +3. Validate + submit as in Recipe A. + +## Recipe C — Pass the URI through the run arguments + +When you want to keep the pipeline YAML stable across runs and only change the +artifact URI per submission — useful for auto-loop iterations that rotate through +staged variants. + +1. In the pipeline YAML, declare an input on the top-level graph: + ```yaml + inputs: + - name: input_artifact_uri + type: URI + ``` +2. Wire it through to the ingest task: + ```yaml + tasks: + ingestInputData: + componentRef: + name: "" + arguments: + uri: + graphInput: {inputName: input_artifact_uri} + ``` +3. Supply the URI per run with `--arg` (or `--args-json`). **Run arguments bind to + the pipeline's top-level `inputs:`** — they are distinct from task-level + `arguments:` wiring inside the pipeline YAML (as shown in step 2 above). See + `uv run tangle sdk pipeline-runs submit --help` for the full surface, and + [`references/tangle-tools.md`](tangle-tools.md) for the canonical submit form. + ```bash + uv run tangle sdk pipeline-runs submit pipeline.yaml \ + --arg input_artifact_uri="://path/to/artifact/" + ``` + +## `arguments:` vs. run arguments (`--arg`) + +Two different things in two different places: + +| What | Where it lives | What it does | +|---|---|---| +| `arguments:` | Pipeline YAML, under each `tasks.:` | Wires a task input to a `constant`, `graphInput`, or `taskOutput`. | +| `--arg K=V` / `--args-json` | Passed to `pipeline-runs submit` on the CLI | Binds values to the pipeline's top-level `inputs:` for a single run. | + +If you mix them up — e.g. you intended a per-run value but left a stale +`constant` wired into the task — the submit may succeed with the wrong input, +leaving the pipeline pointed at a stale or unrelated artifact. Verify before +submit: grep the pipeline YAML for the `graphInput` you expect, and confirm the +`--arg` name matches the declared `inputs:` name exactly. + +## Staging the bytes + +Where the artifact lives is backend-specific. Under the OSS `tangle` backend the +storage provider is HuggingFace, so URIs are scheme-agnostic and look like: + +``` +hf://{model|dataset|space}s//@/ +``` + +- **Push bytes** with a generic client — for `hf://` URIs, `huggingface_hub` + (`HfApi().upload_file(...)` / `upload_folder(...)`). +- **Read the resulting `uri` scheme-agnostically.** Don't hard-code a scheme; the + ingest component receives whatever URI you give it. See + [`OSS-CONVENTIONS.md` §5](../OSS-CONVENTIONS.md) for the artifact-metadata and + signed-URL fetch recipes. + +If the upload requires a credential, prefer reading it without leaving it in shell +history: + +```bash +read -rs HF_TOKEN # paste, then Enter — input is not echoed +export HF_TOKEN +``` + +Pipeline-side credentials belong in `secrets` (`dynamicData.secret`), never +hard-coded into the YAML. + +## Gotchas + +- **Trailing slash matters.** A URI like `://b/p/file` is treated as a + single blob; `://b/p/dir/` is treated as a directory prefix and copied + recursively. The ingest component's `Data` output is shaped accordingly — + downstream tasks must read it as a file vs. a directory. +- **Unique filenames.** Stage each variant under a unique path (a per-session or + per-round prefix, or a content hash in the filename). If you overwrite the same + URI between runs, an earlier run that re-reads it can pick up the wrong bytes, + and you lose the ability to tell two runs apart by their input. One artifact, + one immutable path. +- **Reference a local component by URL when iterating.** For a registered + component you normally pin `componentRef: {digest: }` (reproducible) or + `componentRef: {name: ""}` (resolves to the latest published version at + hydrate time). While you're still editing a component locally, swap the + digest/name for a file reference so the pipeline picks up your edits without a + publish round-trip: + ```yaml + componentRef: + url: "file:///abs/path/to/component.yaml" + ``` + Swap back to a `digest:` (or `name:`) once the component is published and stable. +- **No ingest component? Read the URI in a Python component.** If your backend + registers no ingest component, generate a small one from a Python source file + that fetches the URI and writes a `Data` output: + `uv run tangle sdk components generate from-python fetch.py --image ` + (build/push the image yourself; `set-container-image` is a stub — do not use + it). See [`agents/builder.md`](../agents/builder.md). +- **Don't hand-roll throwaway ingest.** If you find yourself shelling out to copy + bytes inside an unrelated container, stop — factor it into a reusable ingest + component (or your backend's, if it has one) so the dependency is visible in the + graph. +- **Privacy.** Be deliberate about where you stage. Treat any shared store as + broadly readable. PII, customer data, credentials, or embargoed models belong in + a private, access-controlled location — never in a shared corpus or registry. + For preserving an artifact for *indefinite reuse* (with provenance) rather than + one-shot ingest, see [`references/data-sources.md`](data-sources.md). From 49f48b1df6d6d3117b9855d131f0fd7819d280cb Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 2 Jul 2026 19:01:41 -0700 Subject: [PATCH 100/111] fix: prepare prerelease packages for PyPI Assisted-By: devx/863bf6dc-7461-4f68-9fcb-a52df3c22dcf --- .github/workflows/release.yaml | 38 ++++++++++++++++++++++++------ packages/tangle-api/README.md | 3 +++ packages/tangle-api/pyproject.toml | 6 ++--- pyproject.toml | 5 ++-- tests/test_packaging.py | 21 +++++++++++++---- uv.lock | 4 ++-- 6 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 packages/tangle-api/README.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fa580c8..44c3775 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,7 +3,7 @@ name: "Publish release to PyPI" on: push: tags: - # Publish on any tag starting with a `v`, e.g., v0.1.0 + # Publish on any tag starting with a `v`, e.g., v0.0.1a2 - v* jobs: @@ -21,12 +21,36 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Install Python 3.13 run: uv python install 3.13 - - name: Build + - name: Build tangle-cli run: uv build - # Check that basic features work and we didn't miss to include crucial files - - name: Smoke test (wheel) - run: uv run --isolated --no-project --with dist/*.whl tangle version - - name: Smoke test (source distribution) - run: uv run --isolated --no-project --with dist/*.tar.gz tangle version + - name: Build tangle-api + run: uv build --package tangle-api + # Check that basic features work and we didn't miss to include crucial files. + # Use package-specific globs so adding tangle-api artifacts does not change + # which distribution is installed for tangle-cli smoke tests. + - name: Smoke test tangle-cli wheel + run: | + uv run --isolated --no-project --with dist/tangle_cli-*.whl tangle version + uv run --isolated --no-project --with dist/tangle_cli-*.whl tangle-cli version + - name: Smoke test tangle-cli source distribution + run: | + uv run --isolated --no-project --with dist/tangle_cli-*.tar.gz tangle version + uv run --isolated --no-project --with dist/tangle_cli-*.tar.gz tangle-cli version + - name: Smoke test tangle-api wheel + run: | + uv run --isolated --no-project \ + --with dist/tangle_cli-*.whl \ + --with dist/tangle_api-*.whl \ + python -c "import importlib.metadata as m; import tangle_api.generated.models; import tangle_api.schema; assert m.version('tangle-api') == m.version('tangle-cli')" + - name: Smoke test tangle-api source distribution + run: | + uv run --isolated --no-project \ + --with dist/tangle_cli-*.tar.gz \ + --with dist/tangle_api-*.tar.gz \ + python -c "import importlib.metadata as m; import tangle_api.generated.models; import tangle_api.schema; assert m.version('tangle-api') == m.version('tangle-cli')" + - name: Smoke test native extra resolution from local dist + run: | + cli_wheel="$(echo dist/tangle_cli-*.whl)" + uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version - name: Publish run: uv publish diff --git a/packages/tangle-api/README.md b/packages/tangle-api/README.md new file mode 100644 index 0000000..f8c2449 --- /dev/null +++ b/packages/tangle-api/README.md @@ -0,0 +1,3 @@ +# tangle-api + +Checked-in generated Tangle API models, operation proxies, and schema snapshot used by `tangle-cli[native]`. diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index 8fd377f..5f1f3ec 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "tangle-api" -version = "0.1.0" +version = "0.0.1a2" description = "Checked-in generated Tangle API models and operation proxies" -readme = "../../README.md" +readme = "README.md" authors = [ { name = "Alexey Volkov", email = "alexey.volkov@ark-kun.com" }, { name = "Tangle authors" }, @@ -10,7 +10,7 @@ authors = [ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", - "tangle-cli==0.1.0", + "tangle-cli==0.0.1a2", ] [build-system] diff --git a/pyproject.toml b/pyproject.toml index 9912f7b..5d6ffa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.0.1a1" +version = "0.0.1a2" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ @@ -28,10 +28,11 @@ Repository = "https://github.com/TangleML/tangle-cli" Issues = "https://github.com/TangleML/tangle-cli/issues" [project.optional-dependencies] -native = ["tangle-api==0.1.0"] +native = ["tangle-api==0.0.1a2"] [project.scripts] tangle = "tangle_cli.cli:main" +tangle-cli = "tangle_cli.cli:main" [build-system] requires = ["uv_build>=0.11.2,<0.12.0"] diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5cb19a3..d585098 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -107,12 +107,18 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: names = archive.namelist() metadata_name = next(name for name in names if name.endswith(".dist-info/METADATA")) metadata = archive.read(metadata_name).decode() + entry_points_name = next(name for name in names if name.endswith(".dist-info/entry_points.txt")) + entry_points = archive.read(entry_points_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 + assert "Version: 0.0.1a2" in metadata + assert "Requires-Dist: tangle-api==0.0.1a2" not in requires_dist + assert "Requires-Dist: tangle-api==0.0.1a2 ; extra == 'native'" in requires_dist + assert "Provides-Extra: native" in metadata + assert "tangle = tangle_cli.cli:main" in entry_points + assert "tangle-cli = tangle_cli.cli:main" in entry_points env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(wheel), str(stubs)])} subprocess.run( @@ -260,8 +266,15 @@ 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() + names = archive.namelist() + assert "tangle_api/schema/__init__.py" in names + assert "tangle_api/schema/openapi.json" in names + 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 "Version: 0.0.1a2" in metadata + assert "Requires-Dist: tangle-cli==0.0.1a2" in requires_dist env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} subprocess.run( diff --git a/uv.lock b/uv.lock index b2b4421..5821114 100644 --- a/uv.lock +++ b/uv.lock @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "tangle-api" -version = "0.1.0" +version = "0.0.1a2" source = { editable = "packages/tangle-api" } dependencies = [ { name = "pydantic" }, @@ -1790,7 +1790,7 @@ requires-dist = [ [[package]] name = "tangle-cli" -version = "0.0.1a1" +version = "0.0.1a2" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, From 1ab260d5a6b68bf98ab5c8c1149160073b3c48b5 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 2 Jul 2026 20:32:21 -0700 Subject: [PATCH 101/111] refactor: make tangle-api leaf via runtime model composition Assisted-By: devx/9c4ad24f-24d3-4eb9-8747-dbf53e3221df --- packages/tangle-api/pyproject.toml | 3 +- .../src/tangle_api/generated/models.py | 294 ++++------------- .../src/tangle_api/generated/operations.py | 55 ++-- .../src/tangle_api/generated/runtime.py} | 2 +- packages/tangle-cli/src/tangle_cli/client.py | 9 +- .../src/tangle_cli/component_publisher.py | 4 +- packages/tangle-cli/src/tangle_cli/models.py | 56 +++- .../src/tangle_cli/openapi/codegen.py | 238 +++----------- pyproject.toml | 4 +- tests/test_codegen.py | 307 ++---------------- tests/test_component_publisher.py | 2 +- tests/test_models.py | 30 +- tests/test_packaging.py | 71 +++- tests/test_static_client.py | 29 +- uv.lock | 12 +- 15 files changed, 341 insertions(+), 775 deletions(-) rename packages/{tangle-cli/src/tangle_cli/generated_runtime.py => tangle-api/src/tangle_api/generated/runtime.py} (94%) diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index 5f1f3ec..ff045ad 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-api" -version = "0.0.1a2" +version = "0.0.1a3" description = "Checked-in generated Tangle API models and operation proxies" readme = "README.md" authors = [ @@ -10,7 +10,6 @@ authors = [ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", - "tangle-cli==0.0.1a2", ] [build-system] diff --git a/packages/tangle-api/src/tangle_api/generated/models.py b/packages/tangle-api/src/tangle_api/generated/models.py index b6cbe7d..af3849c 100644 --- a/packages/tangle-api/src/tangle_api/generated/models.py +++ b/packages/tangle-api/src/tangle_api/generated/models.py @@ -9,11 +9,9 @@ from pydantic import Field -from tangle_cli.generated_runtime import TangleGeneratedModel +from tangle_api.generated.runtime import TangleGeneratedModel -from tangle_cli.generated_model_extensions import ComponentSpecExtensions, GetExecutionInfoResponseExtensions, GetGraphExecutionStateResponseExtensions - -class _ArtifactDataGenerated(TangleGeneratedModel): +class ArtifactData(TangleGeneratedModel): created_at: Any = None deleted_at: Any = None extra_data: Any = None @@ -23,25 +21,16 @@ class _ArtifactDataGenerated(TangleGeneratedModel): uri: Any = None value: Any = None -class ArtifactData(_ArtifactDataGenerated): - pass - -class _ArtifactDataResponseGenerated(TangleGeneratedModel): +class ArtifactDataResponse(TangleGeneratedModel): is_dir: Any = None total_size: Any = None uri: Any = None value: Any = None -class ArtifactDataResponse(_ArtifactDataResponseGenerated): - pass - -class _ArtifactNodeIdResponseGenerated(TangleGeneratedModel): +class ArtifactNodeIdResponse(TangleGeneratedModel): id: Any = None -class ArtifactNodeIdResponse(_ArtifactNodeIdResponseGenerated): - pass - -class _ArtifactNodeResponseGenerated(TangleGeneratedModel): +class ArtifactNodeResponse(TangleGeneratedModel): artifact_data: Any = None id: Any = None producer_execution_id: Any = None @@ -49,59 +38,35 @@ class _ArtifactNodeResponseGenerated(TangleGeneratedModel): type_name: Any = None type_properties: Any = None -class ArtifactNodeResponse(_ArtifactNodeResponseGenerated): - pass - -class _BodyCreateApiPipelineRunsPostGenerated(TangleGeneratedModel): +class BodyCreateApiPipelineRunsPost(TangleGeneratedModel): annotations: Any = None components: Any = None root_task: Any = None -class BodyCreateApiPipelineRunsPost(_BodyCreateApiPipelineRunsPostGenerated): - pass - -class _BodyCreateSecretApiSecretsPostGenerated(TangleGeneratedModel): +class BodyCreateSecretApiSecretsPost(TangleGeneratedModel): secret_value: Any = None -class BodyCreateSecretApiSecretsPost(_BodyCreateSecretApiSecretsPostGenerated): - pass - -class _BodySetSettingsApiUsersMeSettingsPatchGenerated(TangleGeneratedModel): +class BodySetSettingsApiUsersMeSettingsPatch(TangleGeneratedModel): settings: Any = None -class BodySetSettingsApiUsersMeSettingsPatch(_BodySetSettingsApiUsersMeSettingsPatchGenerated): - pass - -class _BodyUpdateSecretApiSecretsSecretNamePutGenerated(TangleGeneratedModel): +class BodyUpdateSecretApiSecretsSecretNamePut(TangleGeneratedModel): secret_value: Any = None -class BodyUpdateSecretApiSecretsSecretNamePut(_BodyUpdateSecretApiSecretsSecretNamePutGenerated): - pass - -class _CachingStrategySpecGenerated(TangleGeneratedModel): +class CachingStrategySpec(TangleGeneratedModel): maxcachestaleness: Any = Field(default=None, alias='maxCacheStaleness') -class CachingStrategySpec(_CachingStrategySpecGenerated): - pass - -class _ComponentLibraryGenerated(TangleGeneratedModel): +class ComponentLibrary(TangleGeneratedModel): annotations: Any = None name: Any = None root_folder: Any = None -class ComponentLibrary(_ComponentLibraryGenerated): - pass - -class _ComponentLibraryFolderGenerated(TangleGeneratedModel): +class ComponentLibraryFolder(TangleGeneratedModel): annotations: Any = None components: Any = None folders: Any = None name: Any = None -class ComponentLibraryFolder(_ComponentLibraryFolderGenerated): - pass - -class _ComponentLibraryResponseGenerated(TangleGeneratedModel): +class ComponentLibraryResponse(TangleGeneratedModel): annotations: Any = None component_count: Any = None created_at: Any = None @@ -112,10 +77,7 @@ class _ComponentLibraryResponseGenerated(TangleGeneratedModel): root_folder: Any = None updated_at: Any = None -class ComponentLibraryResponse(_ComponentLibraryResponseGenerated): - pass - -class _ComponentReferenceGenerated(TangleGeneratedModel): +class ComponentReference(TangleGeneratedModel): digest: Any = None name: Any = None spec: Any = None @@ -123,17 +85,11 @@ class _ComponentReferenceGenerated(TangleGeneratedModel): text: Any = None url: Any = None -class ComponentReference(_ComponentReferenceGenerated): - pass - -class _ComponentResponseGenerated(TangleGeneratedModel): +class ComponentResponse(TangleGeneratedModel): digest: Any = None text: Any = None -class ComponentResponse(_ComponentResponseGenerated): - pass - -class _ComponentSpecGenerated(TangleGeneratedModel): +class ComponentSpec(TangleGeneratedModel): description: Any = None implementation: Any = None inputs: Any = None @@ -141,82 +97,49 @@ class _ComponentSpecGenerated(TangleGeneratedModel): name: Any = None outputs: Any = None -class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): - pass - -class _ConcatPlaceholderGenerated(TangleGeneratedModel): +class ConcatPlaceholder(TangleGeneratedModel): concat: Any = None -class ConcatPlaceholder(_ConcatPlaceholderGenerated): - pass - ContainerExecutionStatus = Any -class _ContainerImplementationGenerated(TangleGeneratedModel): +class ContainerImplementation(TangleGeneratedModel): container: Any = None -class ContainerImplementation(_ContainerImplementationGenerated): - pass - -class _ContainerSpecGenerated(TangleGeneratedModel): +class ContainerSpec(TangleGeneratedModel): args: Any = None command: Any = None env: Any = None image: Any = None -class ContainerSpec(_ContainerSpecGenerated): - pass - -class _DynamicDataArgumentGenerated(TangleGeneratedModel): +class DynamicDataArgument(TangleGeneratedModel): dynamicdata: Any = Field(default=None, alias='dynamicData') -class DynamicDataArgument(_DynamicDataArgumentGenerated): - pass - -class _ExecutionNodeReferenceGenerated(TangleGeneratedModel): +class ExecutionNodeReference(TangleGeneratedModel): execution_node_id: Any = None pipeline_run_id: Any = None -class ExecutionNodeReference(_ExecutionNodeReferenceGenerated): - pass - -class _ExecutionOptionsSpecGenerated(TangleGeneratedModel): +class ExecutionOptionsSpec(TangleGeneratedModel): cachingstrategy: Any = Field(default=None, alias='cachingStrategy') retrystrategy: Any = Field(default=None, alias='retryStrategy') -class ExecutionOptionsSpec(_ExecutionOptionsSpecGenerated): - pass - -class _ExecutionStatusSummaryGenerated(TangleGeneratedModel): +class ExecutionStatusSummary(TangleGeneratedModel): ended_executions: Any = None has_ended: Any = None total_executions: Any = None -class ExecutionStatusSummary(_ExecutionStatusSummaryGenerated): - pass - -class _GetArtifactInfoResponseGenerated(TangleGeneratedModel): +class GetArtifactInfoResponse(TangleGeneratedModel): artifact_data: Any = None id: Any = None -class GetArtifactInfoResponse(_GetArtifactInfoResponseGenerated): - pass - -class _GetArtifactSignedUrlResponseGenerated(TangleGeneratedModel): +class GetArtifactSignedUrlResponse(TangleGeneratedModel): signed_url: Any = None -class GetArtifactSignedUrlResponse(_GetArtifactSignedUrlResponseGenerated): - pass - -class _GetContainerExecutionLogResponseGenerated(TangleGeneratedModel): +class GetContainerExecutionLogResponse(TangleGeneratedModel): log_text: Any = None orchestration_error_message: Any = None system_error_exception_full: Any = None -class GetContainerExecutionLogResponse(_GetContainerExecutionLogResponseGenerated): - pass - -class _GetContainerExecutionStateResponseGenerated(TangleGeneratedModel): +class GetContainerExecutionStateResponse(TangleGeneratedModel): debug_info: Any = None ended_at: Any = None execution_nodes_linked_to_same_container_execution: Any = None @@ -224,17 +147,11 @@ class _GetContainerExecutionStateResponseGenerated(TangleGeneratedModel): started_at: Any = None status: Any = None -class GetContainerExecutionStateResponse(_GetContainerExecutionStateResponseGenerated): - pass - -class _GetExecutionArtifactsResponseGenerated(TangleGeneratedModel): +class GetExecutionArtifactsResponse(TangleGeneratedModel): input_artifacts: Any = None output_artifacts: Any = None -class GetExecutionArtifactsResponse(_GetExecutionArtifactsResponseGenerated): - pass - -class _GetExecutionInfoResponseGenerated(TangleGeneratedModel): +class GetExecutionInfoResponse(TangleGeneratedModel): child_task_execution_ids: Any = None id: Any = None input_artifacts: Any = None @@ -243,76 +160,43 @@ class _GetExecutionInfoResponseGenerated(TangleGeneratedModel): pipeline_run_id: Any = None task_spec: Any = None -class GetExecutionInfoResponse(GetExecutionInfoResponseExtensions, _GetExecutionInfoResponseGenerated): - pass - -class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel): +class GetGraphExecutionStateResponse(TangleGeneratedModel): child_execution_status_stats: Any = None child_execution_status_summary: Any = None -class GetGraphExecutionStateResponse(GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated): - pass - -class _GetUserResponseGenerated(TangleGeneratedModel): +class GetUserResponse(TangleGeneratedModel): id: Any = None permissions: Any = None -class GetUserResponse(_GetUserResponseGenerated): - pass - -class _GraphImplementationGenerated(TangleGeneratedModel): +class GraphImplementation(TangleGeneratedModel): graph: Any = None -class GraphImplementation(_GraphImplementationGenerated): - pass - -class _GraphInputArgumentGenerated(TangleGeneratedModel): +class GraphInputArgument(TangleGeneratedModel): graphinput: Any = Field(default=None, alias='graphInput') -class GraphInputArgument(_GraphInputArgumentGenerated): - pass - -class _GraphInputReferenceGenerated(TangleGeneratedModel): +class GraphInputReference(TangleGeneratedModel): inputname: Any = Field(default=None, alias='inputName') type: Any = None -class GraphInputReference(_GraphInputReferenceGenerated): - pass - -class _GraphSpecGenerated(TangleGeneratedModel): +class GraphSpec(TangleGeneratedModel): outputvalues: Any = Field(default=None, alias='outputValues') tasks: Any = None -class GraphSpec(_GraphSpecGenerated): - pass - -class _HTTPValidationErrorGenerated(TangleGeneratedModel): +class HTTPValidationError(TangleGeneratedModel): detail: Any = None -class HTTPValidationError(_HTTPValidationErrorGenerated): - pass - -class _IfPlaceholderGenerated(TangleGeneratedModel): +class IfPlaceholder(TangleGeneratedModel): if_: Any = Field(default=None, alias='if') -class IfPlaceholder(_IfPlaceholderGenerated): - pass - -class _IfPlaceholderStructureGenerated(TangleGeneratedModel): +class IfPlaceholderStructure(TangleGeneratedModel): cond: Any = None else_: Any = Field(default=None, alias='else') then: Any = None -class IfPlaceholderStructure(_IfPlaceholderStructureGenerated): - pass - -class _InputPathPlaceholderGenerated(TangleGeneratedModel): +class InputPathPlaceholder(TangleGeneratedModel): inputpath: Any = Field(default=None, alias='inputPath') -class InputPathPlaceholder(_InputPathPlaceholderGenerated): - pass - -class _InputSpecGenerated(TangleGeneratedModel): +class InputSpec(TangleGeneratedModel): annotations: Any = None default: Any = None description: Any = None @@ -320,69 +204,39 @@ class _InputSpecGenerated(TangleGeneratedModel): optional: Any = None type: Any = None -class InputSpec(_InputSpecGenerated): - pass - -class _InputValuePlaceholderGenerated(TangleGeneratedModel): +class InputValuePlaceholder(TangleGeneratedModel): inputvalue: Any = Field(default=None, alias='inputValue') -class InputValuePlaceholder(_InputValuePlaceholderGenerated): - pass - -class _IsPresentPlaceholderGenerated(TangleGeneratedModel): +class IsPresentPlaceholder(TangleGeneratedModel): ispresent: Any = Field(default=None, alias='isPresent') -class IsPresentPlaceholder(_IsPresentPlaceholderGenerated): - pass - -class _ListComponentLibrariesResponseGenerated(TangleGeneratedModel): +class ListComponentLibrariesResponse(TangleGeneratedModel): component_libraries: Any = None -class ListComponentLibrariesResponse(_ListComponentLibrariesResponseGenerated): - pass - -class _ListPipelineJobsResponseGenerated(TangleGeneratedModel): +class ListPipelineJobsResponse(TangleGeneratedModel): next_page_token: Any = None pipeline_runs: Any = None -class ListPipelineJobsResponse(_ListPipelineJobsResponseGenerated): - pass - -class _ListPublishedComponentsResponseGenerated(TangleGeneratedModel): +class ListPublishedComponentsResponse(TangleGeneratedModel): published_components: Any = None -class ListPublishedComponentsResponse(_ListPublishedComponentsResponseGenerated): - pass - -class _ListSecretsResponseGenerated(TangleGeneratedModel): +class ListSecretsResponse(TangleGeneratedModel): secrets: Any = None -class ListSecretsResponse(_ListSecretsResponseGenerated): - pass - -class _MetadataSpecGenerated(TangleGeneratedModel): +class MetadataSpec(TangleGeneratedModel): annotations: Any = None labels: Any = None -class MetadataSpec(_MetadataSpecGenerated): - pass - -class _OutputPathPlaceholderGenerated(TangleGeneratedModel): +class OutputPathPlaceholder(TangleGeneratedModel): outputpath: Any = Field(default=None, alias='outputPath') -class OutputPathPlaceholder(_OutputPathPlaceholderGenerated): - pass - -class _OutputSpecGenerated(TangleGeneratedModel): +class OutputSpec(TangleGeneratedModel): annotations: Any = None description: Any = None name: Any = None type: Any = None -class OutputSpec(_OutputSpecGenerated): - pass - -class _PipelineRunResponseGenerated(TangleGeneratedModel): +class PipelineRunResponse(TangleGeneratedModel): annotations: Any = None created_at: Any = None created_by: Any = None @@ -392,10 +246,7 @@ class _PipelineRunResponseGenerated(TangleGeneratedModel): pipeline_name: Any = None root_execution_id: Any = None -class PipelineRunResponse(_PipelineRunResponseGenerated): - pass - -class _PublishedComponentResponseGenerated(TangleGeneratedModel): +class PublishedComponentResponse(TangleGeneratedModel): deprecated: Any = None digest: Any = None name: Any = None @@ -403,68 +254,41 @@ class _PublishedComponentResponseGenerated(TangleGeneratedModel): superseded_by: Any = None url: Any = None -class PublishedComponentResponse(_PublishedComponentResponseGenerated): - pass - -class _RetryStrategySpecGenerated(TangleGeneratedModel): +class RetryStrategySpec(TangleGeneratedModel): maxretries: Any = Field(default=None, alias='maxRetries') -class RetryStrategySpec(_RetryStrategySpecGenerated): - pass - -class _SecretInfoResponseGenerated(TangleGeneratedModel): +class SecretInfoResponse(TangleGeneratedModel): created_at: Any = None description: Any = None expires_at: Any = None secret_name: Any = None updated_at: Any = None -class SecretInfoResponse(_SecretInfoResponseGenerated): - pass - -class _TaskOutputArgumentGenerated(TangleGeneratedModel): +class TaskOutputArgument(TangleGeneratedModel): taskoutput: Any = Field(default=None, alias='taskOutput') -class TaskOutputArgument(_TaskOutputArgumentGenerated): - pass - -class _TaskOutputReferenceGenerated(TangleGeneratedModel): +class TaskOutputReference(TangleGeneratedModel): outputname: Any = Field(default=None, alias='outputName') taskid: Any = Field(default=None, alias='taskId') -class TaskOutputReference(_TaskOutputReferenceGenerated): - pass - -class _TaskSpecGenerated(TangleGeneratedModel): +class TaskSpec(TangleGeneratedModel): annotations: Any = None arguments: Any = None componentref: Any = Field(default=None, alias='componentRef') executionoptions: Any = Field(default=None, alias='executionOptions') isenabled: Any = Field(default=None, alias='isEnabled') -class TaskSpec(_TaskSpecGenerated): - pass - -class _UserComponentLibraryPinsResponseGenerated(TangleGeneratedModel): +class UserComponentLibraryPinsResponse(TangleGeneratedModel): component_library_ids: Any = None -class UserComponentLibraryPinsResponse(_UserComponentLibraryPinsResponseGenerated): - pass - -class _UserSettingsResponseGenerated(TangleGeneratedModel): +class UserSettingsResponse(TangleGeneratedModel): settings: Any = None -class UserSettingsResponse(_UserSettingsResponseGenerated): - pass - -class _ValidationErrorGenerated(TangleGeneratedModel): +class ValidationError(TangleGeneratedModel): ctx: Any = None input: Any = None loc: Any = None msg: Any = None type: Any = None -class ValidationError(_ValidationErrorGenerated): - pass - __all__ = ['ArtifactData', 'ArtifactDataResponse', 'ArtifactNodeIdResponse', 'ArtifactNodeResponse', 'BodyCreateApiPipelineRunsPost', 'BodyCreateSecretApiSecretsPost', 'BodySetSettingsApiUsersMeSettingsPatch', 'BodyUpdateSecretApiSecretsSecretNamePut', 'CachingStrategySpec', 'ComponentLibrary', 'ComponentLibraryFolder', 'ComponentLibraryResponse', 'ComponentReference', 'ComponentResponse', 'ComponentSpec', 'ConcatPlaceholder', 'ContainerExecutionStatus', 'ContainerImplementation', 'ContainerSpec', 'DynamicDataArgument', 'ExecutionNodeReference', 'ExecutionOptionsSpec', 'ExecutionStatusSummary', 'GetArtifactInfoResponse', 'GetArtifactSignedUrlResponse', 'GetContainerExecutionLogResponse', 'GetContainerExecutionStateResponse', 'GetExecutionArtifactsResponse', 'GetExecutionInfoResponse', 'GetGraphExecutionStateResponse', 'GetUserResponse', 'GraphImplementation', 'GraphInputArgument', 'GraphInputReference', 'GraphSpec', 'HTTPValidationError', 'IfPlaceholder', 'IfPlaceholderStructure', 'InputPathPlaceholder', 'InputSpec', 'InputValuePlaceholder', 'IsPresentPlaceholder', 'ListComponentLibrariesResponse', 'ListPipelineJobsResponse', 'ListPublishedComponentsResponse', 'ListSecretsResponse', 'MetadataSpec', 'OutputPathPlaceholder', 'OutputSpec', 'PipelineRunResponse', 'PublishedComponentResponse', 'RetryStrategySpec', 'SecretInfoResponse', 'TaskOutputArgument', 'TaskOutputReference', 'TaskSpec', 'UserComponentLibraryPinsResponse', 'UserSettingsResponse', 'ValidationError'] diff --git a/packages/tangle-api/src/tangle_api/generated/operations.py b/packages/tangle-api/src/tangle_api/generated/operations.py index c946eb9..e0694c7 100644 --- a/packages/tangle-api/src/tangle_api/generated/operations.py +++ b/packages/tangle-api/src/tangle_api/generated/operations.py @@ -26,6 +26,11 @@ def _request_json( response_model: Any = None, ) -> Any: ... + def _response_model(self, model_name: str, default: Any) -> Any: + """Return the model class used to deserialize a generated response.""" + + return default + def admin_execution_node_status(self, id: Any, status: Any) -> None: return self._request_json( 'PUT', @@ -63,7 +68,7 @@ def artifacts_get(self, id: Any) -> GetArtifactInfoResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetArtifactInfoResponse, + response_model=self._response_model('GetArtifactInfoResponse', GetArtifactInfoResponse), ) def artifacts_signed_artifact_url(self, id: Any) -> GetArtifactSignedUrlResponse: @@ -73,7 +78,7 @@ def artifacts_signed_artifact_url(self, id: Any) -> GetArtifactSignedUrlResponse path_params={'id': id}, params=None, json_data=None, - response_model=GetArtifactSignedUrlResponse, + response_model=self._response_model('GetArtifactSignedUrlResponse', GetArtifactSignedUrlResponse), ) def component_libraries_list(self, name_substring: Any = None) -> ListComponentLibrariesResponse: @@ -83,7 +88,7 @@ def component_libraries_list(self, name_substring: Any = None) -> ListComponentL path_params=None, params={'name_substring': name_substring}, json_data=None, - response_model=ListComponentLibrariesResponse, + response_model=self._response_model('ListComponentLibrariesResponse', ListComponentLibrariesResponse), ) def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: @@ -93,7 +98,7 @@ def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> path_params=None, params={'hide_from_search': hide_from_search}, json_data={'name': name}, - response_model=ComponentLibraryResponse, + response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) def component_libraries_get(self, id: Any, include_component_texts: Any = None) -> ComponentLibraryResponse: @@ -103,7 +108,7 @@ def component_libraries_get(self, id: Any, include_component_texts: Any = None) path_params={'id': id}, params={'include_component_texts': include_component_texts}, json_data=None, - response_model=ComponentLibraryResponse, + response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: @@ -113,7 +118,7 @@ def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = path_params={'id': id}, params={'hide_from_search': hide_from_search}, json_data={'name': name}, - response_model=ComponentLibraryResponse, + response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) def component_library_pins_me(self) -> UserComponentLibraryPinsResponse: @@ -123,7 +128,7 @@ def component_library_pins_me(self) -> UserComponentLibraryPinsResponse: path_params=None, params=None, json_data=None, - response_model=UserComponentLibraryPinsResponse, + response_model=self._response_model('UserComponentLibraryPinsResponse', UserComponentLibraryPinsResponse), ) def component_library_pins_put_me(self, body: Any = None) -> None: @@ -143,7 +148,7 @@ def components_get(self, digest: Any) -> ComponentResponse: path_params={'digest': digest}, params=None, json_data=None, - response_model=ComponentResponse, + response_model=self._response_model('ComponentResponse', ComponentResponse), ) def executions_artifacts(self, id: Any) -> GetExecutionArtifactsResponse: @@ -153,7 +158,7 @@ def executions_artifacts(self, id: Any) -> GetExecutionArtifactsResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetExecutionArtifactsResponse, + response_model=self._response_model('GetExecutionArtifactsResponse', GetExecutionArtifactsResponse), ) def executions_container_log(self, id: Any) -> GetContainerExecutionLogResponse: @@ -163,7 +168,7 @@ def executions_container_log(self, id: Any) -> GetContainerExecutionLogResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetContainerExecutionLogResponse, + response_model=self._response_model('GetContainerExecutionLogResponse', GetContainerExecutionLogResponse), ) def executions_container_state(self, id: Any, include_execution_nodes_linked_to_same_container_execution: Any = None) -> GetContainerExecutionStateResponse: @@ -173,7 +178,7 @@ def executions_container_state(self, id: Any, include_execution_nodes_linked_to_ path_params={'id': id}, params={'include_execution_nodes_linked_to_same_container_execution': include_execution_nodes_linked_to_same_container_execution}, json_data=None, - response_model=GetContainerExecutionStateResponse, + response_model=self._response_model('GetContainerExecutionStateResponse', GetContainerExecutionStateResponse), ) def executions_details(self, id: Any) -> GetExecutionInfoResponse: @@ -183,7 +188,7 @@ def executions_details(self, id: Any) -> GetExecutionInfoResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetExecutionInfoResponse, + response_model=self._response_model('GetExecutionInfoResponse', GetExecutionInfoResponse), ) def executions_graph_execution_state(self, id: Any) -> GetGraphExecutionStateResponse: @@ -193,7 +198,7 @@ def executions_graph_execution_state(self, id: Any) -> GetGraphExecutionStateRes path_params={'id': id}, params=None, json_data=None, - response_model=GetGraphExecutionStateResponse, + response_model=self._response_model('GetGraphExecutionStateResponse', GetGraphExecutionStateResponse), ) def executions_state(self, id: Any) -> GetGraphExecutionStateResponse: @@ -203,7 +208,7 @@ def executions_state(self, id: Any) -> GetGraphExecutionStateResponse: path_params={'id': id}, params=None, json_data=None, - response_model=GetGraphExecutionStateResponse, + response_model=self._response_model('GetGraphExecutionStateResponse', GetGraphExecutionStateResponse), ) def pipeline_runs_list(self, page_token: Any = None, filter: Any = None, filter_query: Any = None, include_pipeline_names: Any = None, include_execution_stats: Any = None) -> ListPipelineJobsResponse: @@ -213,7 +218,7 @@ def pipeline_runs_list(self, page_token: Any = None, filter: Any = None, filter_ path_params=None, params={'page_token': page_token, 'filter': filter, 'filter_query': filter_query, 'include_pipeline_names': include_pipeline_names, 'include_execution_stats': include_execution_stats}, json_data=None, - response_model=ListPipelineJobsResponse, + response_model=self._response_model('ListPipelineJobsResponse', ListPipelineJobsResponse), ) def pipeline_runs_create(self, body: Any = None) -> PipelineRunResponse: @@ -223,7 +228,7 @@ def pipeline_runs_create(self, body: Any = None) -> PipelineRunResponse: path_params=None, params=None, json_data=body, - response_model=PipelineRunResponse, + response_model=self._response_model('PipelineRunResponse', PipelineRunResponse), ) def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> PipelineRunResponse: @@ -233,7 +238,7 @@ def pipeline_runs_get(self, id: Any, include_execution_stats: Any = None) -> Pip path_params={'id': id}, params={'include_execution_stats': include_execution_stats}, json_data=None, - response_model=PipelineRunResponse, + response_model=self._response_model('PipelineRunResponse', PipelineRunResponse), ) def pipeline_runs_annotations(self, id: Any) -> dict[str, Any]: @@ -283,7 +288,7 @@ def published_components_list(self, include_deprecated: Any = None, name_substri path_params=None, params={'include_deprecated': include_deprecated, 'name_substring': name_substring, 'published_by_substring': published_by_substring, 'digest': digest}, json_data=None, - response_model=ListPublishedComponentsResponse, + response_model=self._response_model('ListPublishedComponentsResponse', ListPublishedComponentsResponse), ) def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None) -> PublishedComponentResponse: @@ -293,7 +298,7 @@ def published_components_create(self, digest: Any = None, name: Any = None, tag: path_params=None, params=None, json_data={key: value for key, value in {'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}.items() if value is not None}, - response_model=PublishedComponentResponse, + response_model=self._response_model('PublishedComponentResponse', PublishedComponentResponse), ) def published_components_update(self, digest: Any, deprecated: Any = None, superseded_by: Any = None) -> PublishedComponentResponse: @@ -303,7 +308,7 @@ def published_components_update(self, digest: Any, deprecated: Any = None, super path_params={'digest': digest}, params={'deprecated': deprecated, 'superseded_by': superseded_by}, json_data=None, - response_model=PublishedComponentResponse, + response_model=self._response_model('PublishedComponentResponse', PublishedComponentResponse), ) def secrets_list(self) -> ListSecretsResponse: @@ -313,7 +318,7 @@ def secrets_list(self) -> ListSecretsResponse: path_params=None, params=None, json_data=None, - response_model=ListSecretsResponse, + response_model=self._response_model('ListSecretsResponse', ListSecretsResponse), ) def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: @@ -323,7 +328,7 @@ def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = path_params=None, params={'secret_name': secret_name, 'description': description, 'expires_at': expires_at}, json_data={'secret_value': secret_value}, - response_model=SecretInfoResponse, + response_model=self._response_model('SecretInfoResponse', SecretInfoResponse), ) def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: @@ -333,7 +338,7 @@ def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = path_params={'secret_name': secret_name}, params={'description': description, 'expires_at': expires_at}, json_data={'secret_value': secret_value}, - response_model=SecretInfoResponse, + response_model=self._response_model('SecretInfoResponse', SecretInfoResponse), ) def secrets_delete(self, secret_name: Any) -> None: @@ -353,7 +358,7 @@ def users_me(self) -> GetUserResponse | None: path_params=None, params=None, json_data=None, - response_model=GetUserResponse, + response_model=self._response_model('GetUserResponse', GetUserResponse), ) def users_me_settings(self, setting_names: Any = None) -> UserSettingsResponse: @@ -363,7 +368,7 @@ def users_me_settings(self, setting_names: Any = None) -> UserSettingsResponse: path_params=None, params={'setting_names': setting_names}, json_data=None, - response_model=UserSettingsResponse, + response_model=self._response_model('UserSettingsResponse', UserSettingsResponse), ) def users_patch_me_settings(self, body: Any = None) -> None: diff --git a/packages/tangle-cli/src/tangle_cli/generated_runtime.py b/packages/tangle-api/src/tangle_api/generated/runtime.py similarity index 94% rename from packages/tangle-cli/src/tangle_cli/generated_runtime.py rename to packages/tangle-api/src/tangle_api/generated/runtime.py index b052b35..aced4f7 100644 --- a/packages/tangle-cli/src/tangle_cli/generated_runtime.py +++ b/packages/tangle-api/src/tangle_api/generated/runtime.py @@ -1,4 +1,4 @@ -"""Runtime helpers shared by generated Tangle API model packages.""" +"""Runtime helpers for generated Tangle API model packages.""" from __future__ import annotations diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index b8440b9..060c960 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -26,11 +26,13 @@ log_http_exchange, tangle_verbose_enabled, ) -from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse from tangle_api.generated.operations import GeneratedTangleApiOperations +from . import models as _cli_models from .logger import Logger, _null_logger, get_default_logger from .models import ( ComponentInfo, + ComponentSpec, + GetExecutionInfoResponse, GraphExecutionState, PipelineRun, RunDetails, @@ -78,6 +80,11 @@ def __init__( self.session = session or requests.Session() self.include_env_credentials = include_env_credentials + def _response_model(self, model_name: str, default: Any) -> Any: + """Use CLI-composed models for generated operation deserialization.""" + + return getattr(_cli_models, model_name, default) + def set_verbose(self, enabled: bool) -> None: """Enable or disable request logging.""" diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index 54966b4..5d8d526 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -22,7 +22,7 @@ from .logger import Logger if TYPE_CHECKING: - from tangle_api.generated.models import ComponentSpec + from tangle_cli.models import ComponentSpec class ProcessingOutcome(str, Enum): @@ -171,7 +171,7 @@ def _component_spec_model(self) -> type[Any]: if self.component_spec_model is not None: return self.component_spec_model try: - from tangle_api.generated.models import ComponentSpec + from tangle_cli.models import ComponentSpec except ModuleNotFoundError as exc: if exc.name == "tangle_api": raise RuntimeError( diff --git a/packages/tangle-cli/src/tangle_cli/models.py b/packages/tangle-cli/src/tangle_cli/models.py index 8ae96da..e7a5247 100644 --- a/packages/tangle-cli/src/tangle_cli/models.py +++ b/packages/tangle-cli/src/tangle_cli/models.py @@ -11,9 +11,56 @@ from dataclasses import asdict, dataclass, field from typing import Any -from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse +from tangle_api.generated import models as _generated_models from .artifacts import ArtifactComponentQuery, ArtifactInfo +from . import generated_model_extensions as _cli_model_extensions + + +def extend_model(public_name: str, base: type[Any], *mixins: type[Any]) -> type[Any]: + """Compose a generated model with downstream mixins in this namespace.""" + + if not mixins: + return base + model = type(public_name, (*mixins, base), {"__module__": __name__}) + model_rebuild = getattr(model, "model_rebuild", None) + if callable(model_rebuild): + model_rebuild() + return model + + +def compose_models(generated_models_module: Any, *extension_modules: Any) -> dict[str, type[Any]]: + """Return generated models composed with MODEL_EXTENSIONS mappings. + + Extension modules declare ``MODEL_EXTENSIONS = {"ModelName": "MixinName"}``. + Composition happens in this downstream namespace and never mutates + ``tangle_api.generated.models``. + """ + + composed: dict[str, type[Any]] = {} + public_names = getattr(generated_models_module, "__all__", None) + if public_names is None: + public_names = [ + name + for name, value in vars(generated_models_module).items() + if not name.startswith("_") and isinstance(value, type) + ] + for public_name in public_names: + base = getattr(generated_models_module, public_name) + mixins: list[type[Any]] = [] + for module in extension_modules: + mapping = getattr(module, "MODEL_EXTENSIONS", {}) + if not isinstance(mapping, dict): + continue + mixin_name = mapping.get(public_name) + if mixin_name: + mixins.append(getattr(module, mixin_name)) + composed[public_name] = extend_model(public_name, base, *mixins) + return composed + + +_COMPOSED_MODELS = compose_models(_generated_models, _cli_model_extensions) +globals().update(_COMPOSED_MODELS) # ---- Execution / Run dataclasses ------------------------------------------- @@ -296,10 +343,9 @@ def from_dict(cls, data: dict[str, Any]) -> SecretInfo: # ---- Components ------------------------------------------------------------ -# ``ComponentSpec`` is generated from OpenAPI and extended in -# ``tangle_cli.generated_model_extensions.ComponentSpecExtensions``. Re-export -# it from this module for compatibility with callers that import domain models -# from ``tangle_cli.models``. +# ``ComponentSpec`` is generated from OpenAPI and extended at import time in +# this module. Re-export it for compatibility with callers that import domain +# models from ``tangle_cli.models``. @dataclass diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index 08c0026..e95842c 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -22,9 +22,7 @@ import tempfile import urllib.parse import urllib.request -from collections import Counter from collections.abc import Sequence -from dataclasses import dataclass from pathlib import Path from typing import Any @@ -40,7 +38,6 @@ _GENERATED_DIR = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" / "generated" DEFAULT_BACKEND_PATH = _REPO_ROOT / "third_party" / "tangle" DEFAULT_OPERATIONS_CLASS_NAME = "GeneratedTangleApiOperations" -DEFAULT_MODEL_EXTENSION_MODULE = "tangle_cli.generated_model_extensions" DEFAULT_MODEL_ALIASES: dict[str, tuple[str, ...]] = { "ComponentSpec": ( "ComponentSpec-Output", @@ -373,171 +370,63 @@ def _apply_request_body_schema_overrides( return output -@dataclass(frozen=True) -class _ModelExtensionRef: - """Import reference for one generated model extension class.""" +def generate_runtime() -> str: + """Generate the small runtime module used by generated Pydantic models.""" - module_name: str - class_name: str - alias: str + return '''"""Runtime helpers for generated Tangle API model packages.""" +from __future__ import annotations -def _model_extension_modules( - model_extension_module: str | Sequence[str] | None, -) -> list[str]: - """Return ordered model extension modules, applying defaults first. +from typing import Any - ``None`` means the built-in default module. A string or sequence appends - downstream modules after the built-in default. The empty-string sentinel - disables the default module and is otherwise ignored. - """ +from pydantic import BaseModel - if model_extension_module is None: - modules: list[str] = [] - elif isinstance(model_extension_module, str): - modules = [model_extension_module] - else: - modules = list(model_extension_module) +try: + from pydantic import ConfigDict +except ImportError: # pragma: no cover - pydantic v1 fallback + ConfigDict = None # type: ignore[assignment] - include_default = True - if "" in modules: - include_default = False - modules = [module for module in modules if module != ""] - ordered = ([DEFAULT_MODEL_EXTENSION_MODULE] if include_default else []) + modules - deduped: list[str] = [] - seen: set[str] = set() - for module in ordered: - if not module or module in seen: - continue - seen.add(module) - deduped.append(module) - return deduped +class TangleGeneratedModel(BaseModel): + """Base for generated response models with dict-like conveniences.""" + if ConfigDict is not None: + model_config = ConfigDict(extra="allow", populate_by_name=True) + else: # pragma: no cover - pydantic v1 fallback + class Config: + extra = "allow" + allow_population_by_field_name = True -def _validate_module_name(module_name: str) -> str: - parts = module_name.split(".") - if not parts or any(not re.fullmatch(r"[A-Za-z_]\w*", part) or keyword.iskeyword(part) for part in parts): - raise ValueError(f"Invalid model extension module name: {module_name!r}") - return module_name + def get(self, key: str, default: Any = None) -> Any: + return self.to_dict().get(key, default) + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] -def _model_extension_mapping(module_name: str) -> dict[str, str]: - """Load and validate a MODEL_EXTENSIONS mapping from an extension module.""" + def to_dict(self) -> dict[str, Any]: + if hasattr(self, "model_dump"): + return self.model_dump(by_alias=True) + return self.dict(by_alias=True) - module_name = _validate_module_name(module_name) - try: - module = importlib.import_module(module_name) - except Exception as exc: # pragma: no cover - importlib preserves details - raise ValueError(f"Could not import model extension module {module_name!r}: {exc}") from exc + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Any: + if hasattr(cls, "model_validate"): + return cls.model_validate(data) + return cls.parse_obj(data) - mapping = getattr(module, "MODEL_EXTENSIONS", None) - if not isinstance(mapping, dict): - raise ValueError( - f"Model extension module {module_name!r} must define a MODEL_EXTENSIONS dict" - ) - extensions: dict[str, str] = {} - for model_name, extension_name in mapping.items(): - if not isinstance(model_name, str) or not isinstance(extension_name, str): - raise ValueError("MODEL_EXTENSIONS keys and values must be strings") - _validate_class_name(model_name) - _validate_class_name(extension_name) - if not hasattr(module, extension_name): - raise ValueError( - f"Model extension module {module_name!r} does not define {extension_name!r}" - ) - extensions[model_name] = extension_name - return extensions - - -def _model_extension_refs( - model_extension_module: str | Sequence[str] | None, -) -> dict[str, list[_ModelExtensionRef]]: - """Resolve model extension refs by generated class in configured order.""" - - refs_by_model: dict[str, list[_ModelExtensionRef]] = {} - raw_refs: list[_ModelExtensionRef] = [] - for module_name in _model_extension_modules(model_extension_module): - for model_name, extension_name in _model_extension_mapping(module_name).items(): - ref = _ModelExtensionRef( - module_name=module_name, - class_name=extension_name, - alias=extension_name, - ) - refs_by_model.setdefault(model_name, []).append(ref) - raw_refs.append(ref) - - unique_ref_keys: list[tuple[str, str]] = [] - seen_ref_keys: set[tuple[str, str]] = set() - for ref in raw_refs: - key = (ref.module_name, ref.class_name) - if key in seen_ref_keys: - continue - seen_ref_keys.add(key) - unique_ref_keys.append(key) - - class_name_counts = Counter(class_name for _, class_name in unique_ref_keys) - alias_counts: Counter[str] = Counter() - aliases_by_ref: dict[tuple[str, str], str] = {} - for module_name, class_name in unique_ref_keys: - if class_name_counts[class_name] == 1: - alias = class_name - else: - alias_base = f"_{_safe_identifier(module_name)}_{class_name}" - alias_counts[alias_base] += 1 - alias = alias_base if alias_counts[alias_base] == 1 else f"{alias_base}_{alias_counts[alias_base]}" - aliases_by_ref[(module_name, class_name)] = alias - - aliased: dict[str, list[_ModelExtensionRef]] = {} - for model_name, refs in refs_by_model.items(): - aliased[model_name] = [] - for ref in refs: - aliased[model_name].append( - _ModelExtensionRef( - module_name=ref.module_name, - class_name=ref.class_name, - alias=aliases_by_ref[(ref.module_name, ref.class_name)], - ) - ) - return aliased - - -def _model_extension_import_lines(refs_by_model: dict[str, list[_ModelExtensionRef]]) -> list[str]: - """Render deterministic import lines for configured model extensions.""" - - refs_by_module: dict[str, list[_ModelExtensionRef]] = {} - for refs in refs_by_model.values(): - for ref in refs: - refs_by_module.setdefault(ref.module_name, []).append(ref) - - lines: list[str] = [] - for module_name, refs in sorted(refs_by_module.items()): - imports: list[str] = [] - seen: set[tuple[str, str]] = set() - for ref in sorted(refs, key=lambda item: (item.class_name, item.alias)): - key = (ref.class_name, ref.alias) - if key in seen: - continue - seen.add(key) - if ref.alias == ref.class_name: - imports.append(ref.class_name) - else: - imports.append(f"{ref.class_name} as {ref.alias}") - lines.append(f"from {module_name} import {', '.join(imports)}") - return lines +__all__ = ["TangleGeneratedModel"] +''' def generate_models( schema: dict[str, Any], - model_extension_module: str | Sequence[str] | None = None, model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None, ) -> str: - """Generate Pydantic model classes and apply configured model extensions.""" + """Generate plain/base Pydantic model classes.""" raw_schemas = schema.get("components", {}).get("schemas", {}) or {} schemas, _ = _apply_model_aliases(raw_schemas, model_aliases) - extension_refs = _model_extension_refs(model_extension_module) lines: list[str] = [ '"""Generated Pydantic models for the checked-in Tangle OpenAPI schema.\n\nDo not edit by hand; run ``uv run python -m tangle_cli.openapi.codegen``.\n"""', "", @@ -547,26 +436,10 @@ def generate_models( "", "from pydantic import Field", "", - "from tangle_cli.generated_runtime import TangleGeneratedModel", + "from tangle_api.generated.runtime import TangleGeneratedModel", "", ] - generated_class_names = { - _class_name(schema_name) - for schema_name, schema_def in schemas.items() - if isinstance(schema_def, dict) - and (schema_def.get("type") in {"object", None} or "properties" in schema_def) - } - used_extensions = { - class_name: extension_refs[class_name] - for class_name in sorted(generated_class_names) - if class_name in extension_refs - } - imports = _model_extension_import_lines(used_extensions) - if imports: - lines.extend(imports) - lines.append("") - exports: list[str] = [] for schema_name, schema_def in sorted(schemas.items(), key=lambda item: _class_name(item[0])): class_name = _class_name(schema_name) @@ -575,9 +448,7 @@ def generate_models( lines.extend([f"{class_name} = Any", ""]) continue properties = schema_def.get("properties") or {} - extension_refs_for_class = used_extensions.get(class_name, []) - generated_base_name = f"_{class_name}Generated" - lines.extend([f"class {generated_base_name}(TangleGeneratedModel):"]) + lines.append(f"class {class_name}(TangleGeneratedModel):") if not properties: lines.append(" pass") else: @@ -588,13 +459,6 @@ def generate_models( else: lines.append(f" {field_name}: Any = None") lines.append("") - extension_bases = [ref.alias for ref in reversed(extension_refs_for_class)] - bases = extension_bases + [generated_base_name] - lines.extend([ - f"class {class_name}({', '.join(bases)}):", - " pass", - "", - ]) lines.append(f"__all__ = {exports!r}") lines.append("") @@ -727,6 +591,11 @@ def generate_operations( " response_model: Any = None,", " ) -> Any: ...", "", + " def _response_model(self, model_name: str, default: Any) -> Any:", + " \"\"\"Return the model class used to deserialize a generated response.\"\"\"", + "", + " return default", + "", ]) used_methods: set[str] = set() @@ -742,7 +611,7 @@ def generate_operations( raw_body_override=bool(operation.operation.get("x-tangle-cli-request-body-schema-override")), ) response_model = _response_model_name(operation.operation, model_ref_aliases) - response_arg = response_model if response_model else "None" + response_arg = f"self._response_model({response_model!r}, {response_model})" if response_model else "None" response_annotation = _response_return_annotation(operation.operation, model_ref_aliases) if signature: def_line = f" def {method_name}(self, {signature}) -> {response_annotation}:" @@ -871,7 +740,6 @@ def generate( generated_dir: str | Path = _GENERATED_DIR, *, operations_class_name: str = DEFAULT_OPERATIONS_CLASS_NAME, - model_extension_module: str | Sequence[str] | None = None, model_aliases: dict[str, Sequence[str] | str] | Sequence[str] | str | None = None, request_body_schemas: dict[str, dict[str, Any]] | Sequence[str] | str | None = None, ) -> tuple[dict[str, Any], list[Path]]: @@ -880,6 +748,7 @@ def generate( output_dir.mkdir(parents=True, exist_ok=True) generated_files = [ output_dir / "__init__.py", + output_dir / "runtime.py", output_dir / "models.py", output_dir / "operations.py", ] @@ -887,15 +756,15 @@ def generate( '"""Generated OpenAPI support modules."""\n', encoding="utf-8", ) - generated_files[1].write_text( + generated_files[1].write_text(generate_runtime(), encoding="utf-8") + generated_files[2].write_text( generate_models( schema, - model_extension_module=model_extension_module, model_aliases=model_aliases, ), encoding="utf-8", ) - generated_files[2].write_text( + generated_files[3].write_text( generate_operations( schema, operations_class_name=operations_class_name, @@ -961,19 +830,6 @@ def main(argv: list[str] | None = None) -> None: f"(default: {DEFAULT_OPERATIONS_CLASS_NAME})." ), ) - parser.add_argument( - "--model-extension-module", - action="append", - default=None, - help=( - "Importable module containing a MODEL_EXTENSIONS mapping from " - "generated model class names to extension class names. Repeat to " - "compose modules in order; later modules override earlier ones. " - "The built-in default module is applied first unless an empty string " - "is passed to disable it. " - f"(default first: {DEFAULT_MODEL_EXTENSION_MODULE})." - ), - ) parser.add_argument( "--model-alias", action="append", @@ -1031,7 +887,6 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) try: _validate_class_name(args.operations_class_name) - _model_extension_refs(args.model_extension_module) _model_alias_mapping(args.model_alias) request_body_schema_overrides = _request_body_schema_mapping(args.request_body_schema) request_body_schema_overrides.update(_request_body_schema_file_mapping(args.request_body_schema_file)) @@ -1073,7 +928,6 @@ def main(argv: list[str] | None = None) -> None: openapi_path, args.out, operations_class_name=args.operations_class_name, - model_extension_module=args.model_extension_module, model_aliases=args.model_alias, request_body_schemas=request_body_schema_overrides, ) diff --git a/pyproject.toml b/pyproject.toml index 5d6ffa1..385ceab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.0.1a2" +version = "0.0.1a3" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ @@ -28,7 +28,7 @@ Repository = "https://github.com/TangleML/tangle-cli" Issues = "https://github.com/TangleML/tangle-cli/issues" [project.optional-dependencies] -native = ["tangle-api==0.0.1a2"] +native = ["tangle-api==0.0.1a3"] [project.scripts] tangle = "tangle_cli.cli:main" diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 9a3b3a0..d7308fe 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -146,7 +146,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[1][0] == "generate" assert calls[1][1]["openapi_path"] == default_snapshot assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[1][1]["model_extension_module"] is None assert calls[1][1]["model_aliases"] is None output = capsys.readouterr().out assert f"Loaded OpenAPI from backend: {backend}" in output @@ -201,7 +200,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[0][1]["model_extension_module"] is None assert calls[0][1]["model_aliases"] is None output = capsys.readouterr().out assert f"Loaded OpenAPI from snapshot: {tmp_path / 'openapi.json'}" in output @@ -266,36 +264,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[0][0] == "generate" assert calls[0][1]["operations_class_name"] == "GeneratedTangleApiExtensions" - assert calls[0][1]["model_extension_module"] is None - assert calls[0][1]["model_aliases"] is None - - -def test_codegen_main_accepts_empty_model_extension_module(monkeypatch, tmp_path) -> None: - calls: list[tuple[str, object]] = [] - - def fake_generate(openapi_path, generated_dir, **kwargs): - calls.append(( - "generate", - { - "openapi_path": openapi_path, - "generated_dir": generated_dir, - **kwargs, - }, - )) - return _schema(), _generated_files(tmp_path) - - monkeypatch.setattr(codegen, "generate", fake_generate) - - codegen.main([ - "--openapi", - str(tmp_path / "openapi.json"), - "--from-snapshot", - "--model-extension-module", - "", - ]) - - assert calls[0][0] == "generate" - assert calls[0][1]["model_extension_module"] == [""] assert calls[0][1]["model_aliases"] is None @@ -344,7 +312,6 @@ def fake_generate(openapi_path, generated_dir, **kwargs): assert calls[1][0] == "generate" assert calls[1][1]["openapi_path"] == default_snapshot assert calls[1][1]["operations_class_name"] == "GeneratedTangleApiOperations" - assert calls[1][1]["model_extension_module"] is None assert calls[1][1]["model_aliases"] is None output = capsys.readouterr().out assert "Loaded OpenAPI from URL: https://example.com/openapi.json" in output @@ -463,16 +430,16 @@ def test_generate_models_adds_default_component_spec_alias() -> None: }, } - models = codegen.generate_models(schema, model_extension_module="") + models = codegen.generate_models(schema) operations = codegen.generate_operations(schema) - assert "class _ComponentSpecGenerated(TangleGeneratedModel):" in models - assert "class ComponentSpec(_ComponentSpecGenerated):" in models - assert "class _ComponentSpecOutputGenerated(TangleGeneratedModel):" in models + assert "class ComponentSpec(TangleGeneratedModel):" in models + assert "class ComponentSpecOutput(TangleGeneratedModel):" in models + assert "_ComponentSpecGenerated" not in models assert "'ComponentSpec'" in models assert "from .models import ComponentSpec" in operations assert "def components_get(self, digest: Any) -> ComponentSpec:" in operations - assert "response_model=ComponentSpec" in operations + assert "response_model=self._response_model('ComponentSpec', ComponentSpec)" in operations def test_component_spec_alias_operation_deserializes_raw_spec(monkeypatch, tmp_path) -> None: @@ -523,8 +490,9 @@ def _request_json(self, *args, response_model=None, **kwargs): assert spec.__class__.__name__ == "ComponentSpec" assert spec.name == "Widget" - assert spec.version == "1" - assert spec.data["name"] == "Widget" + assert spec.metadata == {"annotations": {"version": "1"}} + assert not hasattr(spec, "version") + assert not hasattr(spec, "data") def test_generate_models_can_disable_default_model_aliases() -> None: @@ -541,10 +509,10 @@ def test_generate_models_can_disable_default_model_aliases() -> None: }, } - models = codegen.generate_models(schema, model_extension_module="", model_aliases="") + models = codegen.generate_models(schema, model_aliases="") - assert "class _ComponentSpecGenerated" not in models - assert "class _ComponentSpecOutputGenerated(TangleGeneratedModel):" in models + assert "class ComponentSpec(" not in models + assert "class ComponentSpecOutput(TangleGeneratedModel):" in models def test_generate_models_supports_custom_model_aliases() -> None: @@ -575,17 +543,17 @@ def test_generate_models_supports_custom_model_aliases() -> None: }, } - models = codegen.generate_models(schema, model_extension_module="", model_aliases=["Widget=WidgetOutput"]) + models = codegen.generate_models(schema, model_aliases=["Widget=WidgetOutput"]) operations = codegen.generate_operations(schema, model_aliases=["Widget=WidgetOutput"]) - assert "class _WidgetGenerated(TangleGeneratedModel):" in models - assert "class Widget(_WidgetGenerated):" in models + assert "class Widget(TangleGeneratedModel):" in models + assert "_WidgetGenerated" not in models assert "from .models import Widget" in operations assert "-> Widget:" in operations - assert "response_model=Widget" in operations + assert "response_model=self._response_model('Widget', Widget)" in operations -def test_generate_models_uses_builtin_model_extension_module_by_default() -> None: +def test_generate_models_are_plain_by_default() -> None: models = codegen.generate_models({ "openapi": "3.1.0", "paths": {}, @@ -601,218 +569,10 @@ def test_generate_models_uses_builtin_model_extension_module_by_default() -> Non }, }) - assert "from tangle_cli.generated_runtime import TangleGeneratedModel" in models - assert ( - "from tangle_cli.generated_model_extensions import " - "GetGraphExecutionStateResponseExtensions" - ) in models - assert "class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel):" in models - assert ( - "class GetGraphExecutionStateResponse(" - "GetGraphExecutionStateResponseExtensions, _GetGraphExecutionStateResponseGenerated):" - ) in models - - -def test_generate_models_can_disable_builtin_model_extension_module() -> None: - models = codegen.generate_models({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "GetGraphExecutionStateResponse": { - "type": "object", - "properties": { - "child_execution_status_stats": {"type": "object"}, - }, - } - } - }, - }, model_extension_module="") - + assert "from tangle_api.generated.runtime import TangleGeneratedModel" in models assert "generated_model_extensions" not in models - assert "class _GetGraphExecutionStateResponseGenerated(TangleGeneratedModel):" in models - assert "class GetGraphExecutionStateResponse(_GetGraphExecutionStateResponseGenerated):" in models - - -def test_generate_supports_model_extension_module(monkeypatch, tmp_path) -> None: - extension_dir = tmp_path / "extensions" - extension_dir.mkdir() - (extension_dir / "demo_extensions.py").write_text( - "class FooResponseExtensions:\n" - " @property\n" - " def id(self):\n" - " return 'extended-id'\n" - " @property\n" - " def demo(self):\n" - " return 'extended'\n" - "\n" - "MODEL_EXTENSIONS = {\n" - " 'FooResponse': 'FooResponseExtensions',\n" - "}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(extension_dir)) - openapi = tmp_path / "openapi.json" - out = tmp_path / "custom_generated_api" - openapi.write_text( - json.dumps({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "FooResponse": { - "type": "object", - "properties": {"id": {"type": "string"}}, - }, - "OtherResponse": { - "type": "object", - "properties": {"id": {"type": "string"}}, - }, - } - }, - }), - encoding="utf-8", - ) - - codegen.generate( - openapi, - out, - model_extension_module="demo_extensions", - ) - - models = (out / "models.py").read_text(encoding="utf-8") - assert "from demo_extensions import FooResponseExtensions" in models - assert "class _FooResponseGenerated(TangleGeneratedModel):" in models - assert "class FooResponse(FooResponseExtensions, _FooResponseGenerated):" in models - assert "class _OtherResponseGenerated(TangleGeneratedModel):" in models - assert "class OtherResponse(_OtherResponseGenerated):" in models - - monkeypatch.syspath_prepend(str(tmp_path)) - generated_models = importlib.import_module("custom_generated_api.models") - response = generated_models.FooResponse(id="generated-id") - assert response.id == "extended-id" - assert response.to_dict()["id"] == "generated-id" - - - -def test_generate_composes_default_and_downstream_model_extensions(monkeypatch, tmp_path) -> None: - extension_dir = tmp_path / "extensions" - extension_dir.mkdir() - (extension_dir / "downstream_extensions.py").write_text( - "class GetGraphExecutionStateResponseExtensions:\n" - " @property\n" - " def status_totals(self):\n" - " return {'DOWNSTREAM': 1}\n" - "\n" - "MODEL_EXTENSIONS = {\n" - " 'GetGraphExecutionStateResponse': 'GetGraphExecutionStateResponseExtensions',\n" - "}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(extension_dir)) - openapi = tmp_path / "openapi.json" - out = tmp_path / "generated_graph_api" - openapi.write_text( - json.dumps({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "GetGraphExecutionStateResponse": { - "type": "object", - "properties": { - "child_execution_status_stats": {"type": "object"}, - }, - }, - } - }, - }), - encoding="utf-8", - ) - - codegen.generate( - openapi, - out, - model_extension_module="downstream_extensions", - ) - - models = (out / "models.py").read_text(encoding="utf-8") - assert ( - "from downstream_extensions import " - "GetGraphExecutionStateResponseExtensions as " - "_downstream_extensions_GetGraphExecutionStateResponseExtensions" - ) in models - assert ( - "from tangle_cli.generated_model_extensions import " - "GetGraphExecutionStateResponseExtensions as " - "_tangle_cli_generated_model_extensions_GetGraphExecutionStateResponseExtensions" - ) in models - assert ( - "class GetGraphExecutionStateResponse(" - "_downstream_extensions_GetGraphExecutionStateResponseExtensions, " - "_tangle_cli_generated_model_extensions_GetGraphExecutionStateResponseExtensions, " - "_GetGraphExecutionStateResponseGenerated):" - ) in models - - monkeypatch.syspath_prepend(str(tmp_path)) - generated_models = importlib.import_module("generated_graph_api.models") - response = generated_models.GetGraphExecutionStateResponse( - child_execution_status_stats={"exec-1": {"FAILED": 1}} - ) - assert response.status_totals == {"DOWNSTREAM": 1} - assert response.failed_execution_ids == ["exec-1"] - - -def test_generate_deduplicates_colliding_extension_aliases(monkeypatch, tmp_path) -> None: - package_dir = tmp_path / "a" - package_dir.mkdir() - (package_dir / "__init__.py").write_text("", encoding="utf-8") - (package_dir / "b.py").write_text( - "class Ext:\n" - " @property\n" - " def source(self):\n" - " return 'a.b'\n" - "\n" - "MODEL_EXTENSIONS = {'Foo': 'Ext'}\n", - encoding="utf-8", - ) - (tmp_path / "a_b.py").write_text( - "class Ext:\n" - " @property\n" - " def source(self):\n" - " return 'a_b'\n" - "\n" - "MODEL_EXTENSIONS = {'Bar': 'Ext'}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(tmp_path)) - openapi = tmp_path / "openapi.json" - out = tmp_path / "alias_collision_api" - openapi.write_text( - json.dumps({ - "openapi": "3.1.0", - "paths": {}, - "components": { - "schemas": { - "Foo": {"type": "object", "properties": {"id": {"type": "string"}}}, - "Bar": {"type": "object", "properties": {"id": {"type": "string"}}}, - } - }, - }), - encoding="utf-8", - ) - - codegen.generate(openapi, out, model_extension_module=["a.b", "a_b"]) - - models = (out / "models.py").read_text(encoding="utf-8") - assert "from a.b import Ext as _a_b_Ext" in models - assert "from a_b import Ext as _a_b_Ext_2" in models - assert "class Foo(_a_b_Ext, _FooGenerated):" in models - assert "class Bar(_a_b_Ext_2, _BarGenerated):" in models - - generated_models = importlib.import_module("alias_collision_api.models") - assert generated_models.Foo().source == "a.b" - assert generated_models.Bar().source == "a_b" + assert "class GetGraphExecutionStateResponse(TangleGeneratedModel):" in models + assert "_GetGraphExecutionStateResponseGenerated" not in models def test_generate_operations_request_body_schema_override_preserves_raw_body(monkeypatch, tmp_path) -> None: @@ -1044,34 +804,6 @@ def test_codegen_main_rejects_invalid_request_body_schema(tmp_path, capsys) -> N assert "not valid JSON" in capsys.readouterr().err -def test_codegen_main_rejects_invalid_model_extension_module(tmp_path, capsys) -> None: - with pytest.raises(SystemExit) as exc_info: - codegen.main([ - "--openapi", - str(tmp_path / "openapi.json"), - "--model-extension-module", - "not-valid!", - ]) - - assert exc_info.value.code == 2 - assert "Invalid model extension module name" in capsys.readouterr().err - - -def test_generate_rejects_invalid_model_extension_mapping(monkeypatch, tmp_path) -> None: - extension_dir = tmp_path / "extensions" - extension_dir.mkdir() - (extension_dir / "bad_extensions.py").write_text( - "MODEL_EXTENSIONS = {'FooResponse': 'MissingExtensions'}\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(str(extension_dir)) - openapi = tmp_path / "openapi.json" - openapi.write_text(json.dumps({"openapi": "3.1.0", "paths": {}}), encoding="utf-8") - - with pytest.raises(ValueError, match="does not define"): - codegen.generate(openapi, tmp_path / "out", model_extension_module="bad_extensions") - - def test_generate_supports_custom_operations_class_name(tmp_path) -> None: openapi = tmp_path / "openapi.json" out = tmp_path / "custom_generated_api" @@ -1214,5 +946,6 @@ def test_generate_operations_uses_concrete_return_annotations() -> None: assert "def nullable_list(self) -> FooResponse | None:" in operations assert "def status_list(self) -> str:" in operations assert "def things_get(self, id: Any) -> FooResponse:" in operations + assert "response_model=self._response_model('FooResponse', FooResponse)" in operations assert "def things_delete(self, id: Any) -> None:" in operations assert "def unknown_list(self) -> Any:" in operations diff --git a/tests/test_component_publisher.py b/tests/test_component_publisher.py index 8a35160..a6ac801 100644 --- a/tests/test_component_publisher.py +++ b/tests/test_component_publisher.py @@ -6,7 +6,7 @@ import yaml -from tangle_api.generated.models import ComponentSpec +from tangle_cli.models import ComponentSpec from tangle_cli.component_publisher import ( ComponentPublishContext, diff --git a/tests/test_models.py b/tests/test_models.py index 9dc189e..25f172f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,17 +2,22 @@ from __future__ import annotations +from types import SimpleNamespace + +from tangle_api.generated import models as generated_models from tangle_api.generated.models import ( ArtifactData, ComponentSpec as GeneratedComponentSpec, GetArtifactInfoResponse, - GetExecutionInfoResponse, + GetExecutionInfoResponse as GeneratedGetExecutionInfoResponse, ) +import tangle_cli.models as model_module from tangle_cli.models import ( ArtifactInfo, ComponentInfo, ComponentSpec, ContainerState, + GetExecutionInfoResponse, GraphExecutionState, PipelineRun, SecretInfo, @@ -61,9 +66,28 @@ def test_failed_execution_ids(self): class TestComponentSpec: def test_component_spec_is_generated_model_with_extensions(self): - assert ComponentSpec is GeneratedComponentSpec + assert ComponentSpec is not GeneratedComponentSpec + assert issubclass(ComponentSpec, GeneratedComponentSpec) assert ComponentSpec.__mro__[1].__name__ == "ComponentSpecExtensions" + def test_compose_models_uses_model_extensions_mapping_without_mutating_generated_models(self): + class DemoComponentSpecMixin: + @property + def demo_marker(self): + return "runtime-extension" + + extension_module = SimpleNamespace( + MODEL_EXTENSIONS={"ComponentSpec": "DemoComponentSpecMixin"}, + DemoComponentSpecMixin=DemoComponentSpecMixin, + ) + + composed = model_module.compose_models(generated_models, extension_module) + + assert composed["ComponentSpec"] is not GeneratedComponentSpec + assert issubclass(composed["ComponentSpec"], GeneratedComponentSpec) + assert composed["ComponentSpec"](name="demo").demo_marker == "runtime-extension" + assert not hasattr(GeneratedComponentSpec(name="base"), "demo_marker") + def test_from_yaml_basic(self): yaml_text = """\ name: my-component @@ -294,6 +318,8 @@ def test_add_official_prefix_idempotent(self): class TestGetExecutionInfoResponse: def test_execution_details_generated_model_has_extensions(self): + assert GetExecutionInfoResponse is not GeneratedGetExecutionInfoResponse + assert issubclass(GetExecutionInfoResponse, GeneratedGetExecutionInfoResponse) assert GetExecutionInfoResponse.__mro__[1].__name__ == "GetExecutionInfoResponseExtensions" def test_from_dict_parses_artifacts(self): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index d585098..c1cbdbf 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import json import os import subprocess @@ -113,9 +114,9 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Version: 0.0.1a2" in metadata - assert "Requires-Dist: tangle-api==0.0.1a2" not in requires_dist - assert "Requires-Dist: tangle-api==0.0.1a2 ; extra == 'native'" in requires_dist + assert "Version: 0.0.1a3" in metadata + assert "Requires-Dist: tangle-api==0.0.1a3" not in requires_dist + assert "Requires-Dist: tangle-api==0.0.1a3 ; extra == 'native'" in requires_dist assert "Provides-Extra: native" in metadata assert "tangle = tangle_cli.cli:main" in entry_points assert "tangle-cli = tangle_cli.cli:main" in entry_points @@ -188,8 +189,9 @@ def test_tangle_cli_wheel_binds_to_consumer_local_tangle_api(tmp_path) -> None: "import tangle_cli.models as domain_models; " "client = TangleApiClient('https://api.test'); " "assert client.consumer_generated_marker() == 'consumer-local-operations'; " - "assert client_module.ComponentSpec is generated_models.ComponentSpec; " - "assert domain_models.ComponentSpec is generated_models.ComponentSpec; " + "assert client_module.ComponentSpec is domain_models.ComponentSpec; " + "assert issubclass(domain_models.ComponentSpec, generated_models.ComponentSpec); " + "assert domain_models.ComponentSpec.source == 'consumer-local'; " "assert generated_models.ComponentSpec.source == 'consumer-local'; " "assert generated_models.__file__.startswith(%r)" % str(consumer_source), ], @@ -237,7 +239,7 @@ def test_codegen_output_imports_as_consumer_local_tangle_api(tmp_path) -> None: encoding="utf-8", ) - codegen.generate(openapi, generated_dir, model_extension_module="") + codegen.generate(openapi, generated_dir) env = {**os.environ, "PYTHONPATH": str(source_root)} subprocess.run( @@ -262,6 +264,58 @@ def test_codegen_output_imports_as_consumer_local_tangle_api(tmp_path) -> None: ) +def test_tangle_api_source_has_no_tangle_cli_imports() -> None: + source_root = _REPO_ROOT / "packages" / "tangle-api" / "src" / "tangle_api" + for source in source_root.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + imported = [node.module or ""] + else: + continue + assert not any( + name == "tangle_cli" or name.startswith("tangle_cli.") + for name in imported + ), f"{source} imports {imported}" + + +def test_tangle_api_wheel_metadata_and_import_are_leaf(tmp_path) -> None: + api_wheel = _build_wheel(tmp_path / "api", "--package", "tangle-api") + with zipfile.ZipFile(api_wheel) as archive: + metadata_name = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + metadata = archive.read(metadata_name).decode() + + requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] + assert "Requires-Dist: pydantic>=2.0" in requires_dist + assert not any("tangle-cli" in line for line in requires_dist) + + env = {**os.environ, "PYTHONPATH": str(api_wheel)} + subprocess.run( + [ + sys.executable, + "-c", + "import importlib.abc\n" + "import sys\n" + "class BlockTangleCli(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path=None, target=None):\n" + " if fullname == 'tangle_cli' or fullname.startswith('tangle_cli.'):\n" + " raise ModuleNotFoundError('blocked tangle_cli import')\n" + " return None\n" + "sys.meta_path.insert(0, BlockTangleCli()); " + "import tangle_api.generated.models as models; " + "assert models.ComponentSpec.__name__ == 'ComponentSpec'; " + "assert not any(name == 'tangle_cli' or name.startswith('tangle_cli.') for name in sys.modules)", + ], + cwd=tmp_path, + env=env, + check=True, + text=True, + capture_output=True, + ) + + def test_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") @@ -273,8 +327,9 @@ def test_native_wheels_provide_static_client_binding(tmp_path) -> None: metadata = archive.read(metadata_name).decode() requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] - assert "Version: 0.0.1a2" in metadata - assert "Requires-Dist: tangle-cli==0.0.1a2" in requires_dist + assert "Version: 0.0.1a3" in metadata + assert "Requires-Dist: pydantic>=2.0" in requires_dist + assert not any("tangle-cli" in line for line in requires_dist) env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} subprocess.run( diff --git a/tests/test_static_client.py b/tests/test_static_client.py index 602df26..9668b13 100644 --- a/tests/test_static_client.py +++ b/tests/test_static_client.py @@ -9,14 +9,14 @@ from tangle_cli.client import TangleApiClient from tangle_cli.logger import CaptureLogger from tangle_api.generated.models import ( - GetExecutionInfoResponse, - GetGraphExecutionStateResponse, + GetExecutionInfoResponse as BaseGetExecutionInfoResponse, + GetGraphExecutionStateResponse as BaseGetGraphExecutionStateResponse, ListPublishedComponentsResponse, PipelineRunResponse, PublishedComponentResponse, SecretInfoResponse, ) -from tangle_cli.models import ComponentSpec +from tangle_cli.models import ComponentSpec, GetExecutionInfoResponse, GetGraphExecutionStateResponse def response(payload: Any = None, status_code: int = 200) -> requests.Response: @@ -43,7 +43,8 @@ def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: return response({}) -def test_generated_graph_state_response_extensions_work_at_runtime() -> None: +def test_cli_graph_state_response_extensions_work_at_runtime() -> None: + assert issubclass(GetGraphExecutionStateResponse, BaseGetGraphExecutionStateResponse) state = GetGraphExecutionStateResponse.from_dict({ "child_execution_status_stats": { "exec-1": {"SUCCEEDED": 2, "FAILED": 1}, @@ -127,6 +128,26 @@ def test_public_static_client_import_and_generated_operation() -> None: assert session.calls[0]["url"] == "https://api.test/api/pipeline_runs/run%2F1" +def test_static_client_generated_operations_use_cli_model_lookup_override() -> None: + session = FakeSession([ + response({ + "id": "exec-1", + "task_spec": {"componentRef": {"spec": {"name": "task"}}}, + "input_artifacts": {}, + "output_artifacts": {}, + }) + ]) + client = TangleApiClient("https://api.test", session=session) + + details = client.executions_details("exec-1") + + assert isinstance(details, GetExecutionInfoResponse) + assert isinstance(details, BaseGetExecutionInfoResponse) + assert details.__class__ is GetExecutionInfoResponse + assert details.tasks == {} + assert details.raw["id"] == "exec-1" + + def test_request_json_instantiates_list_response_models() -> None: session = FakeSession([ response([{"id": "run-1", "root_execution_id": "exec-1", "created_by": "alice"}]) diff --git a/uv.lock b/uv.lock index 5821114..e4479d5 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T17:04:09.933107Z" +exclude-newer = "2026-06-26T03:30:40.780335Z" exclude-newer-span = "P7D" [manifest] @@ -1775,22 +1775,18 @@ wheels = [ [[package]] name = "tangle-api" -version = "0.0.1a2" +version = "0.0.1a3" source = { editable = "packages/tangle-api" } dependencies = [ { name = "pydantic" }, - { name = "tangle-cli" }, ] [package.metadata] -requires-dist = [ - { name = "pydantic", specifier = ">=2.0" }, - { name = "tangle-cli", editable = "." }, -] +requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [[package]] name = "tangle-cli" -version = "0.0.1a2" +version = "0.0.1a3" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, From 5c15051dd20431bc9c4dd6fdc25bd167d5e98670 Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 3 Jul 2026 07:14:16 -0700 Subject: [PATCH 102/111] build: include tangle-api in default install Assisted-By: devx/9c4ad24f-24d3-4eb9-8747-dbf53e3221df --- .github/workflows/release.yaml | 10 +- README.md | 103 ++++++------------ packages/tangle-api/README.md | 4 +- .../tangle-cli/src/tangle_cli/__init__.py | 8 +- packages/tangle-cli/src/tangle_cli/api_cli.py | 12 +- .../tangle-cli/src/tangle_cli/cli_helpers.py | 13 ++- .../src/tangle_cli/component_publisher.py | 2 +- packages/tangle-cli/src/tangle_cli/handler.py | 2 +- .../src/tangle_cli/openapi/parser.py | 7 +- .../src/tangle_cli/pipeline_run_search.py | 6 +- .../tangle-cli/src/tangle_cli/quickstart.py | 23 ++-- pyproject.toml | 4 +- tests/test_api_cli.py | 6 +- tests/test_artifacts_cli.py | 5 +- tests/test_components_cli.py | 5 +- tests/test_packaging.py | 22 ++-- uv.lock | 10 +- 17 files changed, 110 insertions(+), 132 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44c3775..a7edf13 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,12 +30,12 @@ jobs: # which distribution is installed for tangle-cli smoke tests. - name: Smoke test tangle-cli wheel run: | - uv run --isolated --no-project --with dist/tangle_cli-*.whl tangle version - uv run --isolated --no-project --with dist/tangle_cli-*.whl tangle-cli version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.whl tangle version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.whl tangle-cli version - name: Smoke test tangle-cli source distribution run: | - uv run --isolated --no-project --with dist/tangle_cli-*.tar.gz tangle version - uv run --isolated --no-project --with dist/tangle_cli-*.tar.gz tangle-cli version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.tar.gz tangle version + uv run --isolated --no-project --find-links dist --with dist/tangle_cli-*.tar.gz tangle-cli version - name: Smoke test tangle-api wheel run: | uv run --isolated --no-project \ @@ -48,7 +48,7 @@ jobs: --with dist/tangle_cli-*.tar.gz \ --with dist/tangle_api-*.tar.gz \ python -c "import importlib.metadata as m; import tangle_api.generated.models; import tangle_api.schema; assert m.version('tangle-api') == m.version('tangle-cli')" - - name: Smoke test native extra resolution from local dist + - name: Smoke test native extra compatibility alias from local dist run: | cli_wheel="$(echo dist/tangle_cli-*.whl)" uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version diff --git a/README.md b/README.md index 7dd3b8c..dcd0396 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ By default `tangle api` uses `--schema-source auto`, which means official static `tangle sdk` commands are hand-written workflows. They can be: -- **local-only**: no generated/native API bindings required, e.g. pipeline validation/layout and component generation; +- **local-only**: no generated API bindings required, e.g. pipeline validation/layout and component generation; - **API-backed**: use the generated client but add domain behavior, e.g. pipeline-run submit payload construction, hydration, artifact lookup, publishing/version checks, or config batching. Current SDK groups include: @@ -86,22 +86,22 @@ Use `--log-type none` for quiet machine-readable runs, and `--log-type file` to The repository contains two Python import packages with different responsibilities: - `tangle_cli` is hand-written. It contains CLI wiring, SDK/business helpers, local pipeline/component workflows, dynamic API discovery, codegen, shared runtime classes, logging, and extension classes. -- `tangle_api` is generated/native. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot. +- `tangle_api` is generated/static. It contains checked-in generated Pydantic models, generated endpoint operation methods, and the official OpenAPI snapshot. -The default `tangle-cli` package keeps the top-level import and local-only SDK commands native-free. Install the native extra when you want static API-backed commands and the handwritten `TangleApiClient` wrapper to use the checked-in generated bindings: +The default public `tangle-cli` package depends on the matching `tangle-api` package, so normal installs include the checked-in generated bindings used by static API-backed commands and the handwritten `TangleApiClient` wrapper: ```bash -pip install 'tangle-cli[native]' +pip install tangle-cli ``` -In this workspace, `uv` installs the workspace `tangle-api` package for development and tests: +The `native` extra remains as a compatibility no-op alias for older install instructions. In this workspace, `uv` installs the workspace `tangle-api` package for development and tests: ```bash uv run tangle api --help uv run tangle sdk pipelines validate pipeline.yaml ``` -If you are embedding `tangle_cli` in a downstream project, you can provide your own local `tangle_api.generated` package produced from your backend schema instead of using this repo's official generated package. +Custom API/codegen users can still run codegen from the fully capable install; generating bindings does not require removing the official `tangle-api` package. For project-local generated APIs, generate into a local source tree such as `src/tangle_api/generated` (and `src/tangle_api/schema/openapi.json` when you want `tangle api --schema-source official`) and run from that project so local `src/tangle_api` shadows site-packages. For packaged custom APIs, publish/provide a distribution named `tangle-api` with a version compatible with this `tangle-cli` release (for example `0.0.1a3+yourorg` for a `tangle-cli` dependency on `tangle-api==0.0.1a3`) via a private index, `--find-links`, or uv sources. As an expert escape hatch, `--no-deps` installs only `tangle-cli` and skips all dependencies, so that environment must manually provide every required runtime dependency plus its generated/custom `tangle_api`; this is acceptable for controlled codegen/custom scenarios but not normal UX. ## Quick command examples @@ -204,9 +204,9 @@ uv run tangle api reset-cache --base-url https://api.example Schema source modes are: -- `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Requires the native `tangle-api` package for official operations. -- `--schema-source official`: only the checked-in official static schema. Requires the native `tangle-api` package. -- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. Does not require the native package. +- `--schema-source auto` (default): official static operations plus cached-only backend extensions when a cache exists. Normal `tangle-cli` installs include the `tangle-api` package needed for official operations; custom API projects can shadow or replace that package as described in the codegen section. +- `--schema-source official`: only the checked-in official static schema from `tangle-api` (or a compatible custom `tangle-api` package on your environment's import path). +- `--schema-source cache`: only the schema previously written by `tangle api refresh` for the selected base URL. This is the custom/source-checkout fallback when a consumer environment does not provide an importable `tangle_api.schema` package. For resource help, put `--schema-source` on the resource group: @@ -306,7 +306,7 @@ existing = client.find_existing_components( `TangleApiClient` is handwritten in `tangle_cli.client` and inherits generated endpoint methods from `tangle_api.generated.operations.GeneratedTangleApiOperations`. The generated endpoint methods call the handwritten transport/request logic. Handwritten semantic helpers such as `find_existing_components(...)` return domain models and normalize common compatibility cases. -The top-level `import tangle_cli` is lightweight and does not import native static bindings. Install the native extra or otherwise provide a local `tangle_api.generated` package before importing `tangle_cli.client`. +The top-level `import tangle_cli` is lightweight and does not import static bindings eagerly. Normal installs include `tangle-api`; source checkouts or downstream embeddings may instead provide a local `tangle_api.generated` package before importing `tangle_cli.client`. ## Codegen/autogen from OpenAPI @@ -337,6 +337,17 @@ uv run python -m tangle_cli.openapi.codegen \ --out src/tangle_api/generated ``` +For a project-local custom API package, write both the schema snapshot and generated modules under that project's source tree, then run tools/tests from the project environment so `src/tangle_api` is earlier on `sys.path` than the official site-packages package: + +```bash +uv run python -m tangle_cli.openapi.codegen \ + --openapi-url https://api.example/openapi.json \ + --openapi src/tangle_api/schema/openapi.json \ + --out src/tangle_api/generated +``` + +That project-local `tangle_api` package can be an editable/package source tree. If you ship the custom API bindings as a wheel or source distribution, use the distribution name `tangle-api` and a compatible version for the `tangle-cli` release you are using. A PEP 440 local version such as `0.0.1a3+yourorg` can satisfy a public `==0.0.1a3` dependency while distinguishing your private build. Provide that package through your private index, `--find-links`, or uv source configuration so the resolver chooses it instead of the public official package. + Generate from a backend checkout explicitly: ```bash @@ -347,48 +358,29 @@ uv run --group codegen python -m tangle_cli.openapi.codegen \ Important codegen options: -- `--out`: directory that receives `__init__.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`. +- `--out`: directory that receives `__init__.py`, `runtime.py`, `models.py`, and `operations.py`. Defaults to `packages/tangle-api/src/tangle_api/generated`. - `--operations-class-name`: generated operations mixin class name. Defaults to `GeneratedTangleApiOperations`. -- `--model-extension-module`: importable module with `MODEL_EXTENSIONS`; repeat to compose modules. - `--model-alias`: expose a stable public model name from one or more source schema names, e.g. `ComponentSpec=ComponentSpecOutput,ComponentSpecInput`. - `--request-body-schema` / `--request-body-schema-file`: override a specific operation's JSON request-body schema without mutating the fetched OpenAPI document. At runtime, more `tangle api ...` commands become available in two ways: -1. Static codegen: regenerate and install/provide a `tangle_api.generated` package for the schema. +1. Static codegen: regenerate and install/provide a local or packaged `tangle_api` package containing `tangle_api.generated` and, for official-schema CLI discovery, `tangle_api.schema`. 2. Dynamic cache: run `tangle api refresh --base-url ...` and use `--schema-source auto` or `--schema-source cache` to expose cached-only operations through the dynamic CLI. -## Generated model extension pattern +The supported workaround hierarchy for custom API consumers is: prefer a project-local `src/tangle_api` package that shadows site-packages for that project; if distributing bindings, prefer a compatible private `tangle-api` distribution; reserve `--no-deps` installs or manual uninstalls of the official package for controlled expert environments where you manually provide all dependencies and the generated/custom `tangle_api` package. -Generated models use a generated implementation base plus a stable public subclass. For example, codegen emits this shape for a model with a handwritten extension: +## Runtime generated model extension pattern + +`tangle_api.generated.models` is a leaf package and codegen emits plain generated Pydantic models directly: ```python -class _ComponentSpecGenerated(TangleGeneratedModel): +class ComponentSpec(TangleGeneratedModel): name: Any = None # generated OpenAPI fields... - -class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): - pass -``` - -The public class is a subclass rather than an alias because the public class name is the stable contract while the generated base can be regenerated. Subclassing lets the public class keep the OpenAPI/Pydantic fields from `_ComponentSpecGenerated` and add or override behavior through normal Python MRO. - -Extension bases are placed to the **left** of the generated base: - -```python -class ComponentSpec(ComponentSpecExtensions, _ComponentSpecGenerated): - pass -``` - -That means extension methods/properties override generated-base behavior when names overlap, while generated fields and `TangleGeneratedModel` runtime helpers such as `to_dict()` remain available. - -The built-in default extension module is: - -```text -tangle_cli.generated_model_extensions ``` -It defines: +Generated models do not import `tangle_cli` and codegen does not bake downstream extension modules into `tangle_api`. Downstream packages compose their own extended model namespace at runtime. In `tangle_cli.models`, the default CLI mixins are declared in `tangle_cli.generated_model_extensions`: ```python MODEL_EXTENSIONS = { @@ -398,42 +390,11 @@ MODEL_EXTENSIONS = { } ``` -During codegen, `tangle_api.generated.models` imports those extension classes from `tangle_cli.generated_model_extensions`. This preserves the package boundary: `tangle_api` remains generated bindings, while `tangle_cli` owns handwritten runtime and extension behavior. - -Downstream projects can layer their own extensions: - -```python -# my_project/tangle_model_extensions.py -class MyComponentSpecExtensions: - @property - def owning_team(self) -> str | None: - return (self.metadata or {}).get("annotations", {}).get("team") - -MODEL_EXTENSIONS = { - "ComponentSpec": "MyComponentSpecExtensions", -} -``` - -```bash -uv run python -m tangle_cli.openapi.codegen \ - --openapi-url https://api.example/openapi.json \ - --out src/tangle_api/generated \ - --model-extension-module my_project.tangle_model_extensions -``` - -The default module is applied first. Repeated `--model-extension-module` values are applied in order, and later/downstream modules become leftmost in the generated public class MRO, so they override earlier/default extensions. If two modules export the same extension class name, codegen imports them with deterministic aliases. - -Pass an empty string to disable built-in default extensions: - -```bash -uv run python -m tangle_cli.openapi.codegen \ - --from-snapshot \ - --model-extension-module "" -``` +`tangle_cli.models.compose_models(...)` reads those mappings and creates subclasses in the `tangle_cli.models` namespace, e.g. `ComponentSpec(ComponentSpecExtensions, tangle_api.generated.models.ComponentSpec)`, without mutating `tangle_api.generated.models`. The generated operations layer also calls `_response_model(model_name, default)` so `TangleApiClient` can deserialize responses into the CLI-composed classes while the base `GeneratedTangleApiOperations` remains downstream-agnostic. -The same empty-string sentinel can disable built-in `--model-alias` defaults. Built-in aliases keep stable public model names such as `ComponentSpec` even when a backend schema uses names like `ComponentSpecOutput` or `ComponentSpecInput`. +Downstream projects can use the same pattern in their own namespace: import base classes from `tangle_api.generated.models`, define method/property-only mixins plus a `MODEL_EXTENSIONS` mapping, and compose subclasses locally. Avoid global monkey-patching of `tangle_api.generated.models`. -Extension classes should be importable from their modules and should not import generated model classes. They should be mixins over generated data, not replacements for generated schemas. +Built-in `--model-alias` defaults still keep stable public model names such as `ComponentSpec` even when a backend schema uses names like `ComponentSpecOutput` or `ComponentSpecInput`. ## Extending SDK behavior diff --git a/packages/tangle-api/README.md b/packages/tangle-api/README.md index f8c2449..20f7c7e 100644 --- a/packages/tangle-api/README.md +++ b/packages/tangle-api/README.md @@ -1,3 +1,5 @@ # tangle-api -Checked-in generated Tangle API models, operation proxies, and schema snapshot used by `tangle-cli[native]`. +Checked-in generated Tangle API models, operation proxies, and schema snapshot used by the default `tangle-cli` install. + +This package is intentionally a leaf package: it depends on Pydantic, but not on `tangle-cli`. Custom API consumers can provide their own compatible distribution named `tangle-api` or a project-local `src/tangle_api` package that shadows the official package in that project environment. diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py index fa83c8d..a1cb5f7 100644 --- a/packages/tangle-cli/src/tangle_cli/__init__.py +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -1,9 +1,9 @@ """tangle-cli public API. -The package import is intentionally lightweight: native static API bindings live -in ``tangle_api.generated`` and may be supplied by the consumer environment. -Import ``tangle_cli.client.TangleApiClient`` explicitly when those generated -bindings are available. +The package import is intentionally lightweight: static API bindings live in +``tangle_api.generated`` (included by default in public installs, or supplied by +source/downstream environments). Import ``tangle_cli.client.TangleApiClient`` +explicitly when generated bindings should be loaded. """ from importlib.metadata import PackageNotFoundError diff --git a/packages/tangle-cli/src/tangle_cli/api_cli.py b/packages/tangle-cli/src/tangle_cli/api_cli.py index 3a496f7..4a80863 100644 --- a/packages/tangle-cli/src/tangle_cli/api_cli.py +++ b/packages/tangle-cli/src/tangle_cli/api_cli.py @@ -503,7 +503,8 @@ def _schema_for_current_invocation() -> dict[str, Any] | None: raise SystemExit( f"No cached OpenAPI schema for {_normalize_base_url(base_url)}. " "Run `tangle api refresh` with the same --base-url/--auth-header/--header options, " - "or install tangle-cli[native] to use the official static schema." + "or use a tangle-cli environment with an official or custom tangle-api package " + "that provides tangle_api.schema." ) return cached @@ -571,9 +572,12 @@ def _api_tail_requests_help(api_tail: list[str]) -> bool: def _missing_official_schema_message() -> str: return ( - "Official static Tangle API commands require the native tangle-api " - "package because the bundled OpenAPI snapshot lives in tangle_api.schema. " - "Install tangle-cli[native], or run `tangle api refresh` and use " + "Official static Tangle API commands require a tangle-api package " + "because the OpenAPI snapshot lives in tangle_api.schema. Normal " + "tangle-cli installs include the official package; custom generated " + "API projects should run with a local src/tangle_api package that " + "shadows site-packages or install a compatible private tangle-api " + "distribution. Otherwise run `tangle api refresh` and use " "`--schema-source cache` for cached backend operations." ) diff --git a/packages/tangle-cli/src/tangle_cli/cli_helpers.py b/packages/tangle-cli/src/tangle_cli/cli_helpers.py index f2c704b..7868fb4 100644 --- a/packages/tangle-cli/src/tangle_cli/cli_helpers.py +++ b/packages/tangle-cli/src/tangle_cli/cli_helpers.py @@ -66,8 +66,8 @@ def api_arg_specs( class LazyTangleApiClient: """Instantiate the generated API client only when a command uses it. - Importing CLI modules must stay native-free so local-only commands can run - without the generated ``tangle_api`` package. This proxy delays importing and + Importing CLI modules must not eagerly load generated bindings, so local-only + commands can run without importing ``tangle_api``. This proxy delays importing and constructing ``TangleApiClient`` until an API method is actually accessed, while keeping CLI-friendly error wording in the CLI helper layer. """ @@ -85,9 +85,10 @@ def _get_client(self) -> Any: except ModuleNotFoundError as exc: if exc.name == "tangle_api": raise SystemExit( - "Native generated Tangle API bindings are required for " - f"{self.command_name}. Install tangle-cli[native] or provide " - "a local tangle_api.generated package." + "Generated Tangle API bindings are required for " + f"{self.command_name}. Install the default tangle-cli package " + "with tangle-api, run from a project where local src/tangle_api " + "shadows site-packages, or install a compatible custom tangle-api package." ) from exc raise @@ -97,7 +98,7 @@ def _get_client(self) -> Any: return self._client def require_available(self) -> None: - """Materialize the client so CLI commands fail before native helper imports.""" + """Materialize the client so CLI commands fail before helper imports.""" self._get_client() diff --git a/packages/tangle-cli/src/tangle_cli/component_publisher.py b/packages/tangle-cli/src/tangle_cli/component_publisher.py index 5d8d526..5db339d 100644 --- a/packages/tangle-cli/src/tangle_cli/component_publisher.py +++ b/packages/tangle-cli/src/tangle_cli/component_publisher.py @@ -176,7 +176,7 @@ def _component_spec_model(self) -> type[Any]: if exc.name == "tangle_api": raise RuntimeError( "Native generated Tangle API bindings are required for component publishing. " - "Install tangle-cli[native] or provide a local tangle_api.generated package." + "Install the default tangle-cli package with tangle-api, run from a project where local src/tangle_api shadows site-packages, or install a compatible custom tangle-api package." ) from exc raise return ComponentSpec diff --git a/packages/tangle-cli/src/tangle_cli/handler.py b/packages/tangle-cli/src/tangle_cli/handler.py index df50c9d..03b7a59 100644 --- a/packages/tangle-cli/src/tangle_cli/handler.py +++ b/packages/tangle-cli/src/tangle_cli/handler.py @@ -64,7 +64,7 @@ def _create_client(self) -> Any | None: if exc.name == "tangle_api": self.log.error( "❌ Native generated Tangle API bindings are required for Tangle API operations. " - "Install tangle-cli[native] or provide a local tangle_api.generated package." + "Install the default tangle-cli package with tangle-api, run from a project where local src/tangle_api shadows site-packages, or install a compatible custom tangle-api package." ) return None raise diff --git a/packages/tangle-cli/src/tangle_cli/openapi/parser.py b/packages/tangle-cli/src/tangle_cli/openapi/parser.py index b5ebcd8..6a024ab 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/parser.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/parser.py @@ -49,9 +49,10 @@ def _load_default_openapi_schema() -> dict[str, Any]: last_error = exc if schema_text is None: raise FileNotFoundError( - "Default OpenAPI snapshot not found. Install tangle-api, run from a " - "source checkout with packages/tangle-api/src/tangle_api/schema/openapi.json, " - "or pass --openapi PATH explicitly." + "Default OpenAPI snapshot not found. Install the default or a compatible " + "custom tangle-api package, run from a source checkout with " + "packages/tangle-api/src/tangle_api/schema/openapi.json, or pass " + "--openapi PATH explicitly." ) from last_error schema = json.loads(schema_text) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py index c87a288..6a1bc3a 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py @@ -28,9 +28,9 @@ class PageChunk: """Metadata for a single page of search results. - Defined locally to keep this module importable without the native - ``tangle-api`` extra; ``tangle_cli.models`` re-exports an equivalent - dataclass when native models are available. + Defined locally to keep this module importable without loading generated + ``tangle-api`` bindings; ``tangle_cli.models`` re-exports an equivalent + dataclass when generated models are imported. """ rows: list[dict[str, Any]] diff --git a/packages/tangle-cli/src/tangle_cli/quickstart.py b/packages/tangle-cli/src/tangle_cli/quickstart.py index 2f38060..c09f15a 100644 --- a/packages/tangle-cli/src/tangle_cli/quickstart.py +++ b/packages/tangle-cli/src/tangle_cli/quickstart.py @@ -1,4 +1,4 @@ -"""Native-free quickstart text for the root ``tangle`` CLI.""" +"""Quickstart text for the root ``tangle`` CLI.""" from __future__ import annotations @@ -7,7 +7,7 @@ from cyclopts import App -app = App(name="quickstart", help="Print a concise native-free guide to the Tangle CLI.") +app = App(name="quickstart", help="Print a concise guide to the Tangle CLI.") QUICKSTART_TEXT = dedent( @@ -76,16 +76,15 @@ dynamic schema discovery, codegen, logging, hydrator/resolver logic, and extension hooks. - tangle_api is the generated/native package: checked-in Pydantic models, - endpoint operation methods, and the official OpenAPI snapshot. Local-only - SDK commands and this quickstart do not need it. Static API-backed commands - need tangle-cli[native] or an equivalent local tangle_api.generated package. + tangle_api is the generated package: checked-in Pydantic models, + endpoint operation methods, and the official OpenAPI snapshot. Public + tangle-cli installs include the matching tangle-api package by default. + Codegen/custom API projects can still generate a local src/tangle_api + package that shadows site-packages, or provide a compatible private + distribution named tangle-api for their environment. - Generated model extensions use private generated bases plus stable public - subclasses, e.g. ComponentSpec(ComponentSpecExtensions, - _ComponentSpecGenerated). Extension bases are left of the generated base in - the MRO, and downstream --model-extension-module values can add/override - behavior while preserving generated fields and stable names. + Generated model extensions are composed at runtime in downstream namespaces + such as tangle_cli.models, leaving tangle_api generated models plain/leaf. Discover more ------------- @@ -105,6 +104,6 @@ @app.default def quickstart() -> None: - """Print a concise native-free guide to the Tangle CLI.""" + """Print a concise guide to the Tangle CLI.""" print(QUICKSTART_TEXT) diff --git a/pyproject.toml b/pyproject.toml index 385ceab..8e4bf86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", "requests>=2.32.0", + "tangle-api==0.0.1a3", "tomli>=2.0; python_version < '3.11'", ] @@ -28,7 +29,8 @@ Repository = "https://github.com/TangleML/tangle-cli" Issues = "https://github.com/TangleML/tangle-cli/issues" [project.optional-dependencies] -native = ["tangle-api==0.0.1a3"] +# Compatibility alias: tangle-api is now part of the default public install. +native = [] [project.scripts] tangle = "tangle_cli.cli:main" diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index c38a98d..c93a575 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -926,8 +926,10 @@ def fail_load_schema(): api_cli.build_app() message = str(exc_info.value) - assert "Official static Tangle API commands require the native tangle-api package" in message - assert "Install tangle-cli[native]" in message + assert "Official static Tangle API commands require a tangle-api package" in message + assert "custom generated API projects" in message + assert "shadows site-packages" in message + assert "compatible private tangle-api distribution" in message assert "--schema-source cache" in message diff --git a/tests/test_artifacts_cli.py b/tests/test_artifacts_cli.py index c7779d8..b7b3ec1 100644 --- a/tests/test_artifacts_cli.py +++ b/tests/test_artifacts_cli.py @@ -161,8 +161,9 @@ def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: else: # pragma: no cover - defensive assertion raise AssertionError("expected missing native API to fail") - assert "Native generated Tangle API bindings are required for artifact commands" in message - assert "Install tangle-cli[native]" in message + assert "Generated Tangle API bindings are required for artifact commands" in message + assert "Install the default tangle-cli package with tangle-api" in message + assert "local src/tangle_api shadows site-packages" in message def test_sdk_artifacts_get_cli_requires_query(tmp_path) -> None: diff --git a/tests/test_components_cli.py b/tests/test_components_cli.py index 1dc4708..5764695 100644 --- a/tests/test_components_cli.py +++ b/tests/test_components_cli.py @@ -385,8 +385,9 @@ def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: with pytest.raises(SystemExit) as exc_info: app(command) message = str(exc_info.value) - assert "Native generated Tangle API bindings are required for published-component commands" in message - assert "Install tangle-cli[native]" in message + assert "Generated Tangle API bindings are required for published-component commands" in message + assert "Install the default tangle-cli package with tangle-api" in message + assert "local src/tangle_api shadows site-packages" in message def test_published_components_publish_log_type_none_suppresses_progress(tmp_path: Path, capsys): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index c1cbdbf..a65aba0 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -8,6 +8,9 @@ import zipfile from pathlib import Path +from packaging.specifiers import SpecifierSet +from packaging.version import Version + from tangle_cli.openapi import codegen @@ -99,7 +102,7 @@ def _write_consumer_tangle_api(path: Path) -> Path: return source_root -def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: +def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api(tmp_path) -> None: wheel = _build_wheel(tmp_path) stubs = tmp_path / "stubs" _write_import_stubs(stubs) @@ -115,8 +118,8 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names assert "Version: 0.0.1a3" in metadata - assert "Requires-Dist: tangle-api==0.0.1a3" not in requires_dist - assert "Requires-Dist: tangle-api==0.0.1a3 ; extra == 'native'" in requires_dist + assert "Requires-Dist: tangle-api==0.0.1a3" in requires_dist + assert not any("extra == 'native'" in line for line in requires_dist) assert "Provides-Extra: native" in metadata assert "tangle = tangle_cli.cli:main" in entry_points assert "tangle-cli = tangle_cli.cli:main" in entry_points @@ -142,7 +145,11 @@ def test_tangle_cli_wheel_imports_without_native_tangle_api(tmp_path) -> None: ) -def test_tangle_cli_wheel_api_refresh_builds_without_native_tangle_api(tmp_path) -> None: +def test_custom_tangle_api_local_version_can_satisfy_cli_pin() -> None: + assert Version("0.0.1a3+yourorg") in SpecifierSet("==0.0.1a3") + + +def test_tangle_cli_wheel_api_refresh_builds_in_expert_no_deps_fallback(tmp_path) -> None: wheel = _build_wheel(tmp_path) stubs = tmp_path / "stubs" _write_import_stubs(stubs) @@ -168,14 +175,15 @@ def test_tangle_cli_wheel_api_refresh_builds_without_native_tangle_api(tmp_path) ) -def test_tangle_cli_wheel_binds_to_consumer_local_tangle_api(tmp_path) -> None: +def test_tangle_cli_wheel_binds_to_project_local_tangle_api_before_official_package(tmp_path) -> None: cli_wheel = _build_wheel(tmp_path / "cli") + api_wheel = _build_wheel(tmp_path / "api", "--package", "tangle-api") consumer_source = _write_consumer_tangle_api(tmp_path / "consumer") stubs = tmp_path / "stubs" _write_runtime_stubs(stubs) env = { **os.environ, - "PYTHONPATH": os.pathsep.join([str(consumer_source), str(cli_wheel), str(stubs)]), + "PYTHONPATH": os.pathsep.join([str(consumer_source), str(cli_wheel), str(api_wheel), str(stubs)]), } subprocess.run( @@ -316,7 +324,7 @@ def test_tangle_api_wheel_metadata_and_import_are_leaf(tmp_path) -> None: ) -def test_native_wheels_provide_static_client_binding(tmp_path) -> None: +def test_default_wheels_provide_static_client_binding(tmp_path) -> None: cli_wheel = _build_wheel(tmp_path / "cli") api_wheel = _build_wheel(tmp_path / "api", "--package", "tangle-api") with zipfile.ZipFile(api_wheel) as archive: diff --git a/uv.lock b/uv.lock index e4479d5..635da2b 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-26T03:30:40.780335Z" +exclude-newer = "2026-06-26T13:32:13.031764Z" exclude-newer-span = "P7D" [manifest] @@ -1798,12 +1798,8 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, { name = "requests" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] - -[package.optional-dependencies] -native = [ { name = "tangle-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.dev-dependencies] @@ -1837,7 +1833,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.0" }, - { name = "tangle-api", marker = "extra == 'native'", editable = "packages/tangle-api" }, + { name = "tangle-api", editable = "packages/tangle-api" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, ] provides-extras = ["native"] From 4602457011feaab6cbb6c37abab2e34e81beb256 Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 4 Jul 2026 20:26:33 -0700 Subject: [PATCH 103/111] docs: add canonical tangent agent skills Assisted-By: devx/9c4ad24f-24d3-4eb9-8747-dbf53e3221df --- README.md | 4 ++ pyproject.toml | 1 + skills/tangent/OSS-CONVENTIONS.md | 52 ++++++++++------ skills/tangent/PORT-README.md | 6 +- skills/tangent/SKILL.md | 5 +- skills/tangent/agents/auth-wizard.md | 2 +- skills/tangent/agents/builder.md | 4 +- skills/tangent/agents/debugger.md | 8 +-- skills/tangent/agents/reporter.md | 3 +- skills/tangent/agents/researcher.md | 4 +- skills/tangent/agents/reviewer.md | 6 +- skills/tangent/references/setup.md | 31 +++++++--- .../tangent/references/step-0-initialize.md | 14 ++--- skills/tangent/references/tangle-tools.md | 33 +++++++---- tests/test_packaging.py | 59 +++++++++++++++++++ 15 files changed, 162 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index dcd0396..5482ae1 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,10 @@ uv run tangle sdk pipelines validate pipeline.yaml Custom API/codegen users can still run codegen from the fully capable install; generating bindings does not require removing the official `tangle-api` package. For project-local generated APIs, generate into a local source tree such as `src/tangle_api/generated` (and `src/tangle_api/schema/openapi.json` when you want `tangle api --schema-source official`) and run from that project so local `src/tangle_api` shadows site-packages. For packaged custom APIs, publish/provide a distribution named `tangle-api` with a version compatible with this `tangle-cli` release (for example `0.0.1a3+yourorg` for a `tangle-cli` dependency on `tangle-api==0.0.1a3`) via a private index, `--find-links`, or uv sources. As an expert escape hatch, `--no-deps` installs only `tangle-cli` and skips all dependencies, so that environment must manually provide every required runtime dependency plus its generated/custom `tangle_api`; this is acceptable for controlled codegen/custom scenarios but not normal UX. +## Agent skills + +This repo includes the Tangent agent-skill bundle under `skills/tangent/`. The bundle was ported from `tangle-cli-lab` and now treats this repository as the canonical source. It drives the public `tangle` / `tangle-cli` command surface, assumes the default `tangle-cli` install includes `tangle-api`, and keeps relative references (`agents/*.md`, `references/*.md`) self-contained for Pi-style skill loaders. The source distribution includes `skills/**` so downstream source-based consumers can inspect or vendor the skill docs with the release. + ## Quick command examples Local-only SDK commands: diff --git a/pyproject.toml b/pyproject.toml index 8e4bf86..239b68a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ build-backend = "uv_build" [tool.uv.build-backend] module-root = "packages/tangle-cli/src" +source-include = ["skills/**"] [tool.uv.sources] cloud-pipelines-backend = { git = "https://github.com/TangleML/tangle", rev = "stable_cli" } diff --git a/skills/tangent/OSS-CONVENTIONS.md b/skills/tangent/OSS-CONVENTIONS.md index 349662f..617f66c 100644 --- a/skills/tangent/OSS-CONVENTIONS.md +++ b/skills/tangent/OSS-CONVENTIONS.md @@ -19,7 +19,9 @@ and `api`) and **never** any internal wrapper/hook layer. ## 1. Invocation rule -**Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo.** +**Keep checkout/dev invocation separate from installed-tool invocation.** + +From a checkout of the `tangle-cli` repo, run commands through the workspace: ```bash uv run tangle quickstart @@ -28,20 +30,31 @@ uv run tangle sdk --help uv run tangle api --help ``` +For a persistent user-level CLI install, prefer uv tools: + +```bash +uv tool install tangle-cli +tangle quickstart +tangle-cli --help +``` + +For one-off execution without a persistent install, use `uvx`: + +```bash +uvx --from tangle-cli tangle --help +``` + +Generic Python environments may also use `pip install tangle-cli`; use +`uv pip install tangle-cli` only inside an explicitly managed virtualenv. + - **Never** prefix a command with an internal env-shim exec wrapper. That internal dev-env tooling must not appear in any ported file. -- As of 2026-06-12 `tangle-cli` is **not yet a public PyPI package** (it is - consumed internally as a vendored git submodule). So install text must say - `uv run tangle …` **from a checkout**. Present the public install path only as a - future state: - - > *Once `tangle-cli` is promoted to the public OSS package, you will be able to - > `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, - > use `uv run tangle …` from a checkout of the repo.* - -- The `[native]` extra is what enables the static API-backed commands and the - handwritten `TangleApiClient` wrapper (see §3). In the `tangle-cli` workspace, `uv` - installs the workspace `tangle-api` package automatically for dev/tests. +- `tangle-cli` is a public PyPI package. The default install includes the + matching `tangle-api` package and supports static API-backed commands plus the + handwritten `TangleApiClient` wrapper (see §3). The old `[native]` extra is + only a compatibility/no-op alias. +- In the `tangle-cli` workspace, `uv` installs the workspace `tangle-api` package + automatically for dev/tests. - Help is standard cyclopts `--help`. **There is no `--help-extended` / `--help-full`.** - Skills live **in-repo** (checked into the `tangle-cli` repo). There is **no internal bundle-refresh step**; relative cross-references (`agents/*.md`, `references/*.md`) @@ -61,7 +74,7 @@ OSS replacement. **Verbs/flags below were verified against the `tangle-cli` sour | Internal | OSS | |---|---| -| ` -- …` | `uv run tangle …` (or installed `tangle …` once promoted) | +| ` -- …` | `uv run tangle …` from a checkout, or installed `tangle …` / `tangle-cli …` | | ` quickstart` | `tangle quickstart` (real; static onboarding text) | | `--help-extended` / `--help-full` | `--help` | | `from import TangleApiClient` | `from tangle_cli.client import TangleApiClient` (see §3) | @@ -161,9 +174,10 @@ existing = client.find_existing_components( A bare `TangleApiClient()` only works against the default localhost backend. - `TangleApiClient` lives in `tangle_cli.client` and inherits generated endpoint methods from `tangle_api.generated.operations.GeneratedTangleApiOperations`. - **Importing it requires the native bindings** — install the `[native]` extra (or - provide a local `tangle_api.generated` package) before `from tangle_cli.client import …`. - The top-level `import tangle_cli` is intentionally native-free. + **Importing it requires generated bindings**. The default `tangle-cli` install + includes `tangle-api`; custom API projects may provide a local or packaged + compatible `tangle_api.generated` package before `from tangle_cli.client import …`. + The top-level `import tangle_cli` intentionally does not eagerly import generated bindings. - **Prefer the CLI over Python snippets for status/polling.** Any internal snippet that calls unverified methods like `get_pipeline_run`, `get_execution_graph_state`, `set_verbose`, `.status_totals`, or `.root_execution_id` must be replaced. The @@ -200,7 +214,9 @@ internal backend's auth verification, and the internal package index **entirely* - **Run links:** replace internal run-URL links (`https:///runs/`) with `/runs/` **or** "inspect via `tangle sdk pipeline-runs details RUN_ID`". - **No internal package index.** Resolve dependencies against public PyPI - (`uv sync` / `tangle-cli[native]`). + (`uv sync` from a checkout, `uv tool install tangle-cli` for a persistent CLI, + or `uvx --from tangle-cli tangle …` for one-off execution). Generic Python + environments may also use `pip install tangle-cli`. - Example for a protected backend: ```bash diff --git a/skills/tangent/PORT-README.md b/skills/tangent/PORT-README.md index e755c8b..fccf8a5 100644 --- a/skills/tangent/PORT-README.md +++ b/skills/tangent/PORT-README.md @@ -38,11 +38,7 @@ references/ ← the loop steps + topic references ## How to invoke -All commands run as **`uv run tangle …` from a checkout of the `tangle-cli` repo**. `tangle-cli` is not yet -a published package, so the public `pip install 'tangle-cli[native]'` / bare `tangle …` -*invocation* form is presented as a future state; elsewhere `tangle` still appears as a bare -command noun in prose and in the §2 CLI map (the naming-surface exception). See -`OSS-CONVENTIONS.md` §1. +Commands can run as **`uv run tangle …` from a checkout of the `tangle-cli` repo**, from a persistent uv tool install (`uv tool install tangle-cli`, then `tangle …` / `tangle-cli …`), or as a one-off uvx command (`uvx --from tangle-cli tangle …`). Elsewhere, `tangle` still appears as a bare command noun in prose and in the §2 CLI map (the naming-surface exception). See `OSS-CONVENTIONS.md` §1. ## What changed from the internal bundle diff --git a/skills/tangent/SKILL.md b/skills/tangent/SKILL.md index 0f122cb..8e8e86f 100644 --- a/skills/tangent/SKILL.md +++ b/skills/tangent/SKILL.md @@ -22,10 +22,7 @@ bundle-refresh step. Relative cross-references in this skill (`agents/*.md`, `references/*.md`) resolve directly on disk. Read [`references/setup.md`](references/setup.md) to set up the CLI and auth. -Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo. (Once `tangle-cli` is promoted to the public OSS package, you will be able to -`pip install 'tangle-cli[native]'` and invoke `tangle …` directly; until then, use -`uv run tangle …` from a checkout.) See -[`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md) §1. +Run commands as `uv run tangle …` from a checkout of the `tangle-cli` repo. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …`. See [`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md) §1. ## Commands diff --git a/skills/tangent/agents/auth-wizard.md b/skills/tangent/agents/auth-wizard.md index 50bc6b6..f069310 100644 --- a/skills/tangent/agents/auth-wizard.md +++ b/skills/tangent/agents/auth-wizard.md @@ -14,7 +14,7 @@ environment variables (see [OSS-CONVENTIONS.md §4](../OSS-CONVENTIONS.md)). ## Tools -**Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo.** +**From a checkout, run commands as `uv run tangle …`. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …`.** Help is standard `--help` (there is no `--help-extended` / `--help-full`). Resolution precedence: explicit CLI option > `--config` file value > environment diff --git a/skills/tangent/agents/builder.md b/skills/tangent/agents/builder.md index 9fe46a2..d3316bc 100644 --- a/skills/tangent/agents/builder.md +++ b/skills/tangent/agents/builder.md @@ -11,9 +11,7 @@ components with local code changes for testing. ## Tools -**Run every `tangle` command via Bash as `uv run tangle …` from a checkout of the `tangle-cli` repo** (see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). Once `tangle-cli` is promoted to the public OSS package you will be able to -`pip install 'tangle-cli[native]'` and invoke `tangle …` directly; until then use -`uv run tangle …`. +**Run every `tangle` command via Bash as `uv run tangle …` from a checkout of the `tangle-cli` repo**. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …` (see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). Run `uv run tangle quickstart` to discover available commands. Use `--help` on any command or group for detailed usage (there is no `--help-extended` / `--help-full`). diff --git a/skills/tangent/agents/debugger.md b/skills/tangent/agents/debugger.md index 8fc6ffb..5277faa 100644 --- a/skills/tangent/agents/debugger.md +++ b/skills/tangent/agents/debugger.md @@ -11,10 +11,10 @@ return a one-line diagnosis. ## Tools -**Always use the `tangle` CLI via Bash.** Run every command as `uv run tangle …` -from a checkout of the `tangle-cli` repo (see `OSS-CONVENTIONS.md` §1). Once `tangle-cli` is promoted to the public OSS package you will be able to `pip install -'tangle-cli[native]'` and invoke `tangle …` directly; until then, use `uv run -tangle …` from a checkout. +**Always use the `tangle` CLI via Bash.** Run commands as `uv run tangle …` +from a checkout of the `tangle-cli` repo. For an installed CLI, prefer +`uv tool install tangle-cli`; for one-off execution, use +`uvx --from tangle-cli tangle …` (see `OSS-CONVENTIONS.md` §1). Run `uv run tangle quickstart` to discover available commands. Use `--help` on any command (or group, e.g. `uv run tangle sdk pipeline-runs --help`) for detailed usage. diff --git a/skills/tangent/agents/reporter.md b/skills/tangent/agents/reporter.md index dde7ab8..9fa96ac 100644 --- a/skills/tangent/agents/reporter.md +++ b/skills/tangent/agents/reporter.md @@ -11,8 +11,7 @@ Generate an ML experiment report. Regenerate from scratch each round. ## Tools **Always use the `tangle` CLI via Bash. Do NOT use any MCP tools.** -Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo (see -[OSS-CONVENTIONS.md §1](../OSS-CONVENTIONS.md)). +Run commands as `uv run tangle …` from a checkout of the `tangle-cli` repo. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …` (see [OSS-CONVENTIONS.md §1](../OSS-CONVENTIONS.md)). Run `uv run tangle quickstart` to discover available commands. Use `--help` on any command for detailed usage. diff --git a/skills/tangent/agents/researcher.md b/skills/tangent/agents/researcher.md index 17e4c4a..ef35344 100644 --- a/skills/tangent/agents/researcher.md +++ b/skills/tangent/agents/researcher.md @@ -13,9 +13,7 @@ Think like an MLE who reads papers and asks "what if we tried this?" ## Tools **Always use the `tangle` CLI via Bash. Do NOT use any MCP tool layer.** -Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo. (Once `tangle-cli` is promoted to the public OSS package you will be able to `pip install -'tangle-cli[native]'` and invoke `tangle …` directly; until then use `uv run tangle -…` from a checkout.) See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1. +Run commands as `uv run tangle …` from a checkout of the `tangle-cli` repo. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …`. See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1. Run `uv run tangle quickstart` to discover available commands. Use `--help` on any command for detailed usage. For broader docs, see `uv run tangle sdk diff --git a/skills/tangent/agents/reviewer.md b/skills/tangent/agents/reviewer.md index f94506d..19d5732 100644 --- a/skills/tangent/agents/reviewer.md +++ b/skills/tangent/agents/reviewer.md @@ -12,8 +12,10 @@ ML methodology issues. Be skeptical. Check the work. ## Tools -**Always use the `tangle` CLI via Bash.** Run every command as `uv run tangle …` -from a checkout of the `tangle-cli` repo. See [OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) +**Always use the `tangle` CLI via Bash.** Run commands as `uv run tangle …` +from a checkout of the `tangle-cli` repo. For an installed CLI, prefer +`uv tool install tangle-cli`; for one-off execution, use +`uvx --from tangle-cli tangle …`. See [OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) §1 for the invocation rule and §4 for auth flags. Run `uv run tangle quickstart` to discover available commands. Use `--help` on any diff --git a/skills/tangent/references/setup.md b/skills/tangent/references/setup.md index 5799a0f..e6284a0 100644 --- a/skills/tangent/references/setup.md +++ b/skills/tangent/references/setup.md @@ -10,8 +10,7 @@ cross-references resolve directly on disk. ## 1. Install / run the CLI -The CLI is consumed from a checkout of the repo. Run every command as -`uv run tangle …`: +From a checkout, run commands through `uv run`: ```bash uv run tangle quickstart @@ -20,14 +19,28 @@ uv run tangle sdk --help uv run tangle api --help ``` +For a persistent user-level CLI install, prefer uv tools: + +```bash +uv tool install tangle-cli +tangle quickstart +tangle-cli --help +``` + +For one-off execution without a persistent install, use `uvx`: + +```bash +uvx --from tangle-cli tangle --help +``` + +Generic Python environments may also use `pip install tangle-cli`; use +`uv pip install tangle-cli` only inside an explicitly managed virtualenv. + `uv` resolves dependencies against public PyPI. In the `tangle-cli` workspace, `uv` installs the workspace `tangle-api` package automatically for dev/tests. The -`[native]` extra enables the static API-backed commands and the handwritten -`TangleApiClient` wrapper. - -> Once `tangle-cli` is promoted to the public OSS package, you will be able to -> `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, -> use `uv run tangle …` from a checkout of the repo. +published default `tangle-cli` install includes `tangle-api`, enabling static +API-backed commands and the handwritten `TangleApiClient` wrapper. The old +`native` extra is retained only as a compatibility/no-op alias. Then discover available commands: ```bash @@ -143,4 +156,4 @@ fetch bytes, use the signed-URL recipe in | `403 Forbidden` | Authenticated but not permitted — re-run with a different `--token` for the right identity. | | Connection error / timeout | Wrong or unreachable `--base-url` / `TANGLE_API_URL`; confirm the backend is up. | | Want to see the raw HTTP exchange | Set `TANGLE_VERBOSE=1` for redacted request/response diagnostics. | -| Import error from `from tangle_cli.client import TangleApiClient` | Install the `[native]` extra (the client needs the native bindings). | +| Import error from `from tangle_cli.client import TangleApiClient` | Install `tangle-cli` normally so `tangle-api` is present, or provide a compatible local/custom `tangle_api.generated` package for a custom API. | diff --git a/skills/tangent/references/step-0-initialize.md b/skills/tangent/references/step-0-initialize.md index 405b9c5..6bb9f8d 100644 --- a/skills/tangent/references/step-0-initialize.md +++ b/skills/tangent/references/step-0-initialize.md @@ -11,17 +11,13 @@ core, run from a checkout of the `tangle-cli` repo: uv run tangle --help ``` -If that fails, work through `references/setup.md` first (checkout, `uv sync`, the -`[native]` extra, base-url/auth). Once `uv run tangle --help` works, discover the -available commands: +If that fails, work through `references/setup.md` first (checkout, `uv tool install tangle-cli`, or `uvx --from tangle-cli tangle …`; base-url/auth). Once `uv run tangle --help` or installed `tangle --help` works, discover the available commands: ```bash uv run tangle quickstart ``` -> Once `tangle-cli` is promoted to the public OSS package you will be able to -> `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, -> use `uv run tangle …` from a checkout of the repo. +> For an installed CLI, prefer `uv tool install tangle-cli` and invoke `tangle …` or `tangle-cli …`; for one-off execution, use `uvx --from tangle-cli tangle …`. ## Scenario Directory @@ -31,8 +27,10 @@ All experiment state lives in the scenario directory. Set the absolute path: SCENARIO_DIR= ``` -All `tangle` commands run via `uv run tangle …`. All file reads/writes use -absolute `SCENARIO_DIR` paths. These are different locations — don't confuse them. +From a checkout, all `tangle` commands run via `uv run tangle …`. From an +installed CLI, use `tangle …` / `tangle-cli …`; for one-off execution, use +`uvx --from tangle-cli tangle …`. All file reads/writes use absolute +`SCENARIO_DIR` paths. These are different locations — don't confuse them. ## No Scenario Yet? Build One diff --git a/skills/tangent/references/tangle-tools.md b/skills/tangent/references/tangle-tools.md index 442f626..308db35 100644 --- a/skills/tangent/references/tangle-tools.md +++ b/skills/tangent/references/tangle-tools.md @@ -14,7 +14,7 @@ with new commands and flags — discover the current surface with `--help` and ## Install / Invoke -Run every command as `uv run tangle …` from a checkout of the `tangle-cli` repo: +From a checkout, run commands as `uv run tangle …`: ```bash uv run tangle quickstart @@ -23,13 +23,24 @@ uv run tangle sdk --help uv run tangle api --help ``` -The `[native]` extra enables the static API-backed commands and the handwritten -`TangleApiClient` (see [Programmatic client](#programmatic-client-python)). In the `tangle-cli` workspace `uv` installs the workspace `tangle-api` package automatically for -dev/tests. +For a persistent user-level CLI install, prefer uv tools: -> Once `tangle-cli` is promoted to the public OSS package, you will be able to -> `pip install 'tangle-cli[native]'` and invoke `tangle …` directly. Until then, -> use `uv run tangle …` from a checkout of the repo. +```bash +uv tool install tangle-cli +tangle quickstart +tangle-cli --help +``` + +For one-off execution without a persistent install, use `uvx`: + +```bash +uvx --from tangle-cli tangle --help +``` + +Generic Python environments may also use `pip install tangle-cli`; use +`uv pip install tangle-cli` only inside an explicitly managed virtualenv. + +The default `tangle-cli` install includes `tangle-api` and enables static API-backed commands plus the handwritten `TangleApiClient` (see [Programmatic client](#programmatic-client-python)). In the `tangle-cli` workspace, `uv` installs the workspace `tangle-api` package automatically for dev/tests. The old `native` extra is a compatibility/no-op alias. ## Discover Available Commands @@ -361,10 +372,10 @@ existing = client.find_existing_components( - Constructor: `TangleApiClient(base_url, *, token=, auth_header=, header=, headers=, …)`. A bare `TangleApiClient()` only works against the default localhost backend. -- Importing it requires the native bindings — install the `[native]` extra (or - provide a local `tangle_api.generated` package) before - `from tangle_cli.client import …`. The top-level `import tangle_cli` is - intentionally native-free. +- Importing it requires generated bindings. The default `tangle-cli` install + includes `tangle-api`; custom API projects can provide a compatible local or + packaged `tangle_api.generated` package before `from tangle_cli.client import …`. + The top-level `import tangle_cli` intentionally does not eagerly import generated bindings. - The verified surface is `client.pipeline_runs_get(run_id)` and the `find_existing_components(...)` helper. For status/graph state, prefer the CLI: `tangle sdk pipeline-runs status RUN_ID` and diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a65aba0..f083f6a 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -5,6 +5,7 @@ import os import subprocess import sys +import tarfile import zipfile from pathlib import Path @@ -26,6 +27,15 @@ def _build_wheel(tmp_path: Path, *args: str) -> Path: return wheels[-1] +def _build_sdist(tmp_path: Path, *args: str) -> Path: + out_dir = tmp_path / "dist" + command = ["uv", "build", "--sdist", "--out-dir", str(out_dir), *args] + subprocess.run(command, cwd=_REPO_ROOT, check=True, text=True, capture_output=True) + sdists = sorted(out_dir.glob("*.tar.gz")) + assert sdists, f"no sdist built by {' '.join(command)}" + return sdists[-1] + + def _write_import_stubs(path: Path) -> None: path.mkdir() _write_runtime_stubs(path) @@ -102,6 +112,55 @@ def _write_consumer_tangle_api(path: Path) -> Path: return source_root +def test_tangent_skill_bundle_is_in_repo_and_current() -> None: + skill_root = _REPO_ROOT / "skills" / "tangent" + expected = [ + "SKILL.md", + "OSS-CONVENTIONS.md", + "PORT-README.md", + "agents/auth-wizard.md", + "agents/builder.md", + "agents/debugger.md", + "agents/reporter.md", + "agents/researcher.md", + "agents/reviewer.md", + "agents/scenario-builder.md", + "references/tangle-tools.md", + "references/setup.md", + "references/step-0-initialize.md", + "references/step-1-analyze.md", + "references/step-2-hypothesize.md", + "references/step-3-submit.md", + "references/step-4-monitor.md", + "references/step-5-evaluate.md", + "references/step-6-synthesize.md", + "references/step-7-decide.md", + ] + for relative in expected: + assert (skill_root / relative).is_file(), relative + + markdown = "\n".join(path.read_text(encoding="utf-8") for path in skill_root.rglob("*.md")) + assert "tangle-cli-lab" not in markdown + assert "not yet a public PyPI package" not in markdown + assert "pip install 'tangle-cli[native]'" not in markdown + assert "uv install" not in markdown + assert "uv tool install tangle-cli" in markdown + assert "uvx --from tangle-cli tangle" in markdown + assert "uv pip install tangle-cli` only inside an explicitly managed virtualenv" in markdown + assert "pip install tangle-cli" in markdown + assert "compatibility/no-op" in markdown + + +def test_tangle_cli_sdist_includes_tangent_skill_bundle(tmp_path) -> None: + sdist = _build_sdist(tmp_path) + with tarfile.open(sdist) as archive: + names = archive.getnames() + + assert any(name.endswith("/skills/tangent/SKILL.md") for name in names) + assert any(name.endswith("/skills/tangent/OSS-CONVENTIONS.md") for name in names) + assert any(name.endswith("/skills/tangent/references/tangle-tools.md") for name in names) + + def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api(tmp_path) -> None: wheel = _build_wheel(tmp_path) stubs = tmp_path / "stubs" From 69bbad98d1f4fbcb4438ce8b3ed06789ab39b2bd Mon Sep 17 00:00:00 2001 From: Volv G Date: Sat, 4 Jul 2026 20:36:15 -0700 Subject: [PATCH 104/111] docs: prefer published tangle-cli in tangent skills Assisted-By: devx/9c4ad24f-24d3-4eb9-8747-dbf53e3221df --- skills/tangent/OSS-CONVENTIONS.md | 43 ++++--- skills/tangent/PORT-README.md | 4 +- skills/tangent/SKILL.md | 8 +- skills/tangent/agents/auth-wizard.md | 10 +- skills/tangent/agents/builder.md | 66 +++++------ skills/tangent/agents/debugger.md | 53 ++++----- skills/tangent/agents/reporter.md | 10 +- skills/tangent/agents/researcher.md | 26 ++--- skills/tangent/agents/reviewer.md | 27 ++--- skills/tangent/agents/scenario-builder.md | 9 +- skills/tangent/references/data-sources.md | 8 +- .../example-scenarios/01-mslr-ranking.md | 16 +-- .../02-text-classification.md | 12 +- .../references/example-scenarios/INDEX.md | 2 +- .../tangent/references/iterating-on-runs.md | 22 ++-- skills/tangent/references/secrets.md | 32 ++--- skills/tangent/references/setup.md | 42 ++++--- .../tangent/references/step-0-initialize.md | 27 +++-- skills/tangent/references/step-3-submit.md | 18 +-- skills/tangent/references/step-4-monitor.md | 12 +- skills/tangent/references/step-5-evaluate.md | 4 +- skills/tangent/references/tangle-tools.md | 110 +++++++++--------- .../tangent/references/uploading-artifacts.md | 22 ++-- tests/test_packaging.py | 3 + 24 files changed, 292 insertions(+), 294 deletions(-) diff --git a/skills/tangent/OSS-CONVENTIONS.md b/skills/tangent/OSS-CONVENTIONS.md index 617f66c..495add2 100644 --- a/skills/tangent/OSS-CONVENTIONS.md +++ b/skills/tangent/OSS-CONVENTIONS.md @@ -19,34 +19,32 @@ and `api`) and **never** any internal wrapper/hook layer. ## 1. Invocation rule -**Keep checkout/dev invocation separate from installed-tool invocation.** - -From a checkout of the `tangle-cli` repo, run commands through the workspace: - -```bash -uv run tangle quickstart -uv run tangle --help -uv run tangle sdk --help -uv run tangle api --help -``` +**Published-package usage is the default; checkout usage is for local `tangle-cli` development/validation.** For a persistent user-level CLI install, prefer uv tools: ```bash uv tool install tangle-cli tangle quickstart -tangle-cli --help +tangle --help +tangle sdk --help +tangle api --help ``` For one-off execution without a persistent install, use `uvx`: ```bash uvx --from tangle-cli tangle --help +uvx --from tangle-cli tangle quickstart ``` Generic Python environments may also use `pip install tangle-cli`; use `uv pip install tangle-cli` only inside an explicitly managed virtualenv. +When intentionally validating a local checkout of the `tangle-cli` repo, prefix +examples with `uv run` (for example, `uv run tangle quickstart`). Skill examples +otherwise use the installed-tool form (`tangle …` / `tangle-cli …`). + - **Never** prefix a command with an internal env-shim exec wrapper. That internal dev-env tooling must not appear in any ported file. - `tangle-cli` is a public PyPI package. The default install includes the @@ -74,7 +72,7 @@ OSS replacement. **Verbs/flags below were verified against the `tangle-cli` sour | Internal | OSS | |---|---| -| ` -- …` | `uv run tangle …` from a checkout, or installed `tangle …` / `tangle-cli …` | +| ` -- …` | `tangle …` / `tangle-cli …` (or `uv run tangle …` only when validating a local checkout) | | ` quickstart` | `tangle quickstart` (real; static onboarding text) | | `--help-extended` / `--help-full` | `--help` | | `from import TangleApiClient` | `from tangle_cli.client import TangleApiClient` (see §3) | @@ -142,9 +140,9 @@ OSS replacement. **Verbs/flags below were verified against the `tangle-cli` sour | `--run-as IDENTITY` | `--run-as` exists on `submit` but the **OSS default hooks do not support it** (downstream extension seam only). Drop run-as examples (§10, D9). | **Invocation rule for the whole table:** every left-column command, however it was -written internally, becomes the right-column form prefixed with `uv run` (e.g. -`uv run tangle sdk pipeline-runs submit …`). Auth flags from §4 attach to any -API-backed command. +written internally, becomes the bare right-column form (for example, +`tangle sdk pipeline-runs submit …`). Only prefix with `uv run` when intentionally +validating a local checkout. Auth flags from §4 attach to any API-backed command. --- @@ -214,13 +212,14 @@ internal backend's auth verification, and the internal package index **entirely* - **Run links:** replace internal run-URL links (`https:///runs/`) with `/runs/` **or** "inspect via `tangle sdk pipeline-runs details RUN_ID`". - **No internal package index.** Resolve dependencies against public PyPI - (`uv sync` from a checkout, `uv tool install tangle-cli` for a persistent CLI, - or `uvx --from tangle-cli tangle …` for one-off execution). Generic Python - environments may also use `pip install tangle-cli`. + (`uv tool install tangle-cli` for a persistent CLI, or + `uvx --from tangle-cli tangle …` for one-off execution). Generic Python + environments may also use `pip install tangle-cli`; local checkout validation + uses `uv sync` / `uv run` inside the repo. - Example for a protected backend: ```bash - uv run tangle sdk pipeline-runs submit pipeline.yaml \ + tangle sdk pipeline-runs submit pipeline.yaml \ --base-url https://api.example \ --auth-header 'Bearer …' \ -H 'X-Gateway-Auth: …' \ @@ -257,11 +256,11 @@ records of the form `{id, uri, size, hash}` (and a `count`); the `uri` is 1. Get metadata and read the `uri`: ```bash - uv run tangle sdk artifacts get RUN_ID -q '{"artifact_ids":[""]}' + tangle sdk artifacts get RUN_ID -q '{"artifact_ids":[""]}' ``` 2. Ask the backend for a signed URL: ```bash - uv run tangle api artifacts signed-artifact-url --id + tangle api artifacts signed-artifact-url --id ``` 3. Fetch with a generic client — `curl -L "" -o ./out`, or for `hf://` URIs `huggingface_hub` (`hf_hub_download` / `snapshot_download`). @@ -317,7 +316,7 @@ selectors, and auth env vars) entirely. The OSS log surface is split by what sto log surface: ```bash - uv run tangle sdk pipeline-runs logs EXECUTION_ID + tangle sdk pipeline-runs logs EXECUTION_ID ``` (Note: this is keyed by **EXECUTION_ID**, not run id.) diff --git a/skills/tangent/PORT-README.md b/skills/tangent/PORT-README.md index fccf8a5..d9b360a 100644 --- a/skills/tangent/PORT-README.md +++ b/skills/tangent/PORT-README.md @@ -38,7 +38,7 @@ references/ ← the loop steps + topic references ## How to invoke -Commands can run as **`uv run tangle …` from a checkout of the `tangle-cli` repo**, from a persistent uv tool install (`uv tool install tangle-cli`, then `tangle …` / `tangle-cli …`), or as a one-off uvx command (`uvx --from tangle-cli tangle …`). Elsewhere, `tangle` still appears as a bare command noun in prose and in the §2 CLI map (the naming-surface exception). See `OSS-CONVENTIONS.md` §1. +Normal usage runs the **published `tangle-cli` package**: install persistently with `uv tool install tangle-cli`, then run `tangle …` / `tangle-cli …`, or use `uvx --from tangle-cli tangle …` for one-off commands. Local checkout invocation (`uv run tangle …`) is only for validating changes inside the `tangle-cli` repo. Elsewhere, `tangle` still appears as a bare command noun in prose and in the §2 CLI map (the naming-surface exception). See `OSS-CONVENTIONS.md` §1. ## What changed from the internal bundle @@ -47,7 +47,7 @@ replaced with the project's open, backend-agnostic equivalent: | Internal coupling (removed) | Open-source replacement | | ---------------------------------- | -------------------------------------------------------------------- | -| Internal CLI wrapper + env shim | `uv run tangle …` (the public CLI), no environment shim | +| Internal CLI wrapper + env shim | `tangle …` / `tangle-cli …` from the published package, no environment shim | | Cloud-object-storage artifact URIs | Scheme-agnostic artifact `uri` (e.g. `hf://…`); fetch via signed URL | | Hosted log-search backend | `tangle sdk pipeline-runs logs` + launcher-native system events | | Hard-coded internal API endpoint | `--base-url` / `TANGLE_API_URL` | diff --git a/skills/tangent/SKILL.md b/skills/tangent/SKILL.md index 8e8e86f..2ab5c5a 100644 --- a/skills/tangent/SKILL.md +++ b/skills/tangent/SKILL.md @@ -1,6 +1,6 @@ --- name: tangent -description: ML experiment toolkit and autonomous agent. Say "tangent" for help, "tangent " for a specific tool, or "tangent auto" for the full autonomous experiment loop. Requires the Tangle CLI (the `tangle-cli` checkout) and a reachable Tangle backend. +description: ML experiment toolkit and autonomous agent. Say "tangent" for help, "tangent " for a specific tool, or "tangent auto" for the full autonomous experiment loop. Requires the published Tangle CLI (`uv tool install tangle-cli` or `uvx --from tangle-cli tangle ...`) and a reachable Tangle backend. allowed-tools: [Bash, Read, Write, Glob, Grep, Agent, dispatch] --- @@ -22,7 +22,7 @@ bundle-refresh step. Relative cross-references in this skill (`agents/*.md`, `references/*.md`) resolve directly on disk. Read [`references/setup.md`](references/setup.md) to set up the CLI and auth. -Run commands as `uv run tangle …` from a checkout of the `tangle-cli` repo. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …`. See [`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md) §1. +Normal usage runs the published CLI: install persistently with `uv tool install tangle-cli` and use `tangle …`, or run one-off commands with `uvx --from tangle-cli tangle …`. If intentionally validating a local `tangle-cli` checkout, prefix examples with `uv run`. See [`OSS-CONVENTIONS.md`](OSS-CONVENTIONS.md) §1. ## Commands @@ -61,10 +61,10 @@ Automation: ## Tools Always use the `tangle` CLI via Bash. Do **not** use any `tangle` MCP tools. -Run `uv run tangle quickstart` to discover commands. See +Run `tangle quickstart` to discover commands. See [`references/tangle-tools.md`](references/tangle-tools.md). -Cancel a run: `uv run tangle sdk pipeline-runs cancel RUN_ID` +Cancel a run: `tangle sdk pipeline-runs cancel RUN_ID` Background execution: `dispatch`. Subagents: `agents/*.md`. diff --git a/skills/tangent/agents/auth-wizard.md b/skills/tangent/agents/auth-wizard.md index f069310..79a20cb 100644 --- a/skills/tangent/agents/auth-wizard.md +++ b/skills/tangent/agents/auth-wizard.md @@ -14,7 +14,7 @@ environment variables (see [OSS-CONVENTIONS.md §4](../OSS-CONVENTIONS.md)). ## Tools -**From a checkout, run commands as `uv run tangle …`. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …`.** +**Use the published `tangle` CLI via Bash.** Install persistently with `uv tool install tangle-cli`, or run one-off commands with `uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if intentionally validating a local `tangle-cli` checkout, prefix examples with `uv run`. Help is standard `--help` (there is no `--help-extended` / `--help-full`). Resolution precedence: explicit CLI option > `--config` file value > environment @@ -80,7 +80,7 @@ chosen in Step 2): **a) Inline flag** — explicit, per-command, highest precedence: ```bash -uv run tangle sdk pipeline-runs search --limit 1 \ +tangle sdk pipeline-runs search --limit 1 \ --base-url https://api.example \ --token '' ``` @@ -90,7 +90,7 @@ uv run tangle sdk pipeline-runs search --limit 1 \ ```bash export TANGLE_API_URL='https://api.example' export TANGLE_API_TOKEN='' -uv run tangle sdk pipeline-runs search --limit 1 +tangle sdk pipeline-runs search --limit 1 ``` For `--auth-header` set `TANGLE_API_AUTH_HEADER`; for `-H` set @@ -105,7 +105,7 @@ token: "" ``` ```bash -uv run tangle sdk pipeline-runs search --limit 1 --config tangle.config.yaml +tangle sdk pipeline-runs search --limit 1 --config tangle.config.yaml ``` **Advise against committing secrets.** Prefer the env var, or keep the @@ -116,7 +116,7 @@ uv run tangle sdk pipeline-runs search --limit 1 --config tangle.config.yaml Run a cheap, read-only call to confirm the credential works: ```bash -uv run tangle sdk pipeline-runs search --limit 1 +tangle sdk pipeline-runs search --limit 1 ``` (attach the auth flags from Step 3 if not using env/`--config`). A clean exit — diff --git a/skills/tangent/agents/builder.md b/skills/tangent/agents/builder.md index d3316bc..5bd8b5c 100644 --- a/skills/tangent/agents/builder.md +++ b/skills/tangent/agents/builder.md @@ -11,28 +11,28 @@ components with local code changes for testing. ## Tools -**Run every `tangle` command via Bash as `uv run tangle …` from a checkout of the `tangle-cli` repo**. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …` (see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). +**Use the published `tangle` CLI via Bash.** Install persistently with `uv tool install tangle-cli`, or run one-off commands with `uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if intentionally validating a local `tangle-cli` checkout, prefix examples with `uv run` (see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). -Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +Run `tangle quickstart` to discover available commands. Use `--help` on any command or group for detailed usage (there is no `--help-extended` / `--help-full`). -For schema and concept docs, use `uv run tangle sdk --help`, -`uv run tangle sdk published-components library`, and the public OSS docs at +For schema and concept docs, use `tangle sdk --help`, +`tangle sdk published-components library`, and the public OSS docs at `github.com/TangleML/website/tree/master/docs`. | What you need | Command | |---|---| -| Export run as YAML | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | -| Inspect a published component | `uv run tangle sdk published-components inspect --name "Name" --full-spec` | -| Search components (optional; may be empty) | `uv run tangle sdk published-components search "keyword"` | -| Curated standard library | `uv run tangle sdk published-components library` | -| Generate from Python | `uv run tangle sdk components generate from-python source.py [--image REG/IMG:TAG]` | -| Bump version | `uv run tangle sdk components bump-version component.yaml` | -| Hydrate refs | `uv run tangle sdk pipelines hydrate template.yaml -o output.yaml` | -| Validate pipeline | `uv run tangle sdk pipelines validate pipeline.yaml` | -| Auto-layout DAG | `uv run tangle sdk pipelines layout pipeline.yaml` | -| Submit pipeline | `uv run tangle sdk pipeline-runs submit pipeline.yaml [--arg K=V \| --args-json JSON]` | -| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | -| Component as used | `uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | +| Export run as YAML | `tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Inspect a published component | `tangle sdk published-components inspect --name "Name" --full-spec` | +| Search components (optional; may be empty) | `tangle sdk published-components search "keyword"` | +| Curated standard library | `tangle sdk published-components library` | +| Generate from Python | `tangle sdk components generate from-python source.py [--image REG/IMG:TAG]` | +| Bump version | `tangle sdk components bump-version component.yaml` | +| Hydrate refs | `tangle sdk pipelines hydrate template.yaml -o output.yaml` | +| Validate pipeline | `tangle sdk pipelines validate pipeline.yaml` | +| Auto-layout DAG | `tangle sdk pipelines layout pipeline.yaml` | +| Submit pipeline | `tangle sdk pipeline-runs submit pipeline.yaml [--arg K=V \| --args-json JSON]` | +| Run details | `tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Component as used | `tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | Component discovery (search/library) is **optional** and **off by default** on a fresh OSS install — treat it as a best-effort lookup and tolerate empty results; @@ -51,8 +51,8 @@ field, a `constantValue:`, a `cli_args:` entry, a run-arg passed via `--arg` / plaintext from your environment or a secret store. Treat raw credential values as poison. -The correct flow is: `uv run tangle sdk secrets list` → if the secret doesn't exist, -ask the human to create it with `uv run tangle sdk secrets create NAME --from-env NAME` +The correct flow is: `tangle sdk secrets list` → if the secret doesn't exist, +ask the human to create it with `tangle sdk secrets create NAME --from-env NAME` (`--from-env`/`-e` reads from an env var so the value never lands in shell history; the agent never touches the value) → reference it via `dynamicData.secret: { name: "NAME" }` on the consuming argument. See @@ -95,7 +95,7 @@ When a pipeline produces an artifact worth keeping past the run's TTL — a curated eval set, a fine-tuned checkpoint, a generated annotation set, a frozen feature snapshot — record its identity so a later pipeline can re-reference it: capture the producing `run_id` and the artifact `uri` -(`uv run tangle sdk artifacts get RUN_ID -q ''` returns `{id, uri, size, hash}` +(`tangle sdk artifacts get RUN_ID -q ''` returns `{id, uri, size, hash}` records; see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5) and save them in the scenario's `MEMORY.md` / session log. A downstream pipeline re-references the artifact by feeding that `uri` into the consuming task argument (typically @@ -137,25 +137,25 @@ guarded pattern. Do not assume those components exist. See [`../references/iterating-on-runs.md`](../references/iterating-on-runs.md) for the full workflow. -1. **Export**: `uv run tangle sdk pipeline-runs export RUN_ID --output /tmp/pipeline.yaml` +1. **Export**: `tangle sdk pipeline-runs export RUN_ID --output /tmp/pipeline.yaml` — exports the root spec as-is (there is no `--dehydrate`). Omit `--output` to print to stdout. -2. **Inspect**: `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` - — identify task statuses. (`uv run tangle sdk pipeline-runs status RUN_ID` gives a +2. **Inspect**: `tangle sdk pipeline-runs details RUN_ID --include-execution-state` + — identify task statuses. (`tangle sdk pipeline-runs status RUN_ID` gives a lighter run + derived status summary.) 3. **Modify**: Edit the exported YAML. To swap a component, replace its `digest:` or `url: file://` reference with a new `url: file://` pointing to your replacement. -4. **Validate**: `uv run tangle sdk pipelines validate /tmp/pipeline.yaml` +4. **Validate**: `tangle sdk pipelines validate /tmp/pipeline.yaml` 5. **Submit** (see Submission Rules in [`../references/tangle-tools.md`](../references/tangle-tools.md)): ```bash - uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ --args-json @/tmp/pipeline.args.json ``` `submit` hydrates by default (it resolves component versions), so there is no "dehydrate first" guard and no `--no-wait` — `submit` returns as soon as the - run is created; poll with `uv run tangle sdk pipeline-runs wait RUN_ID`. Pass run + run is created; poll with `tangle sdk pipeline-runs wait RUN_ID`. Pass run arguments with `--arg K=V` / `--args-json ''` (or `--args-json @file.json`); `--config` carries CLI-option defaults (base-url/auth/log-type), not run args. @@ -167,7 +167,7 @@ yourself with your own tooling (docker/podman), then point the component at it. 1. **Find source code**: Inspect the published component to get source annotations: ```bash - uv run tangle sdk published-components inspect --name "Component Name" --full-spec + tangle sdk published-components inspect --name "Component Name" --full-spec ``` Check the annotations the publisher attached (e.g. source path, repo, image, docs references) to locate the source. @@ -179,7 +179,7 @@ yourself with your own tooling (docker/podman), then point the component at it. 3. **Generate the component YAML** from your Python entrypoint, pointing at the image you pushed: ```bash - uv run tangle sdk components generate from-python source.py --image registry.example/img:tag + tangle sdk components generate from-python source.py --image registry.example/img:tag ``` 4. **Insert into pipeline**: In the exported YAML, change the task's `componentRef` @@ -193,16 +193,16 @@ what you need. Component discovery is off by default on a fresh OSS install, so treat this as best-effort and tolerate empty results: ```bash # Optional keyword/name/digest search (may return nothing on a fresh install): -uv run tangle sdk published-components search "" +tangle sdk published-components search "" # Curated standard library: -uv run tangle sdk published-components library +tangle sdk published-components library ``` There are no v2/semantic/fuzzy/regex/schema search variants in OSS. If discovery returns nothing, just generate the component you need. **From Python**: ```bash -uv run tangle sdk components generate from-python my_module.py +tangle sdk components generate from-python my_module.py ``` Generates component YAML from a Python function. Looks for a function matching the filename by default; use `--function ` to pick a different one. Pass @@ -214,10 +214,10 @@ yourself. `generate from-python` is the only generation path — there is no ```bash # Bump version first -uv run tangle sdk components bump-version component.yaml +tangle sdk components bump-version component.yaml # Publish -uv run tangle sdk published-components publish component.yaml +tangle sdk published-components publish component.yaml ``` `bump-version` lives under `components`; `publish` lives under `published-components`. To publish several at once, pass a `--config` YAML/JSON @@ -230,7 +230,7 @@ Always validate before submitting. See Submission Rules in [`../references/tangle-tools.md`](../references/tangle-tools.md) for the full pre-submit checklist. ```bash -uv run tangle sdk pipelines validate pipeline.yaml +tangle sdk pipelines validate pipeline.yaml ``` Use `--verbose` only if validation fails and you need full error details. diff --git a/skills/tangent/agents/debugger.md b/skills/tangent/agents/debugger.md index 5277faa..92776c8 100644 --- a/skills/tangent/agents/debugger.md +++ b/skills/tangent/agents/debugger.md @@ -11,29 +11,30 @@ return a one-line diagnosis. ## Tools -**Always use the `tangle` CLI via Bash.** Run commands as `uv run tangle …` -from a checkout of the `tangle-cli` repo. For an installed CLI, prefer -`uv tool install tangle-cli`; for one-off execution, use -`uvx --from tangle-cli tangle …` (see `OSS-CONVENTIONS.md` §1). - -Run `uv run tangle quickstart` to discover available commands. Use `--help` on any -command (or group, e.g. `uv run tangle sdk pipeline-runs --help`) for detailed usage. +**Always use the published `tangle` CLI via Bash.** Install persistently with +`uv tool install tangle-cli`, or run one-off commands with +`uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if +intentionally validating a local `tangle-cli` checkout, prefix examples with +`uv run` (see `OSS-CONVENTIONS.md` §1). + +Run `tangle quickstart` to discover available commands. Use `--help` on any +command (or group, e.g. `tangle sdk pipeline-runs --help`) for detailed usage. There is no `--help-extended` / `--help-full` and no `docs` command — for -debugging guidance, lean on `--help`, `uv run tangle sdk published-components library`, +debugging guidance, lean on `--help`, `tangle sdk published-components library`, and the public OSS docs at [github.com/TangleML/website/tree/master/docs](https://github.com/TangleML/website/tree/master/docs). | What you need | Command | |---|---| -| Run state & derived status summary | `uv run tangle sdk pipeline-runs status RUN_ID` | -| Execution tree & task states | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | -| Graph execution state (per execution) | `uv run tangle sdk pipeline-runs graph-state EXECUTION_ID` | -| Container logs (application stack traces, code errors) | `uv run tangle sdk pipeline-runs logs EXECUTION_ID` | +| Run state & derived status summary | `tangle sdk pipeline-runs status RUN_ID` | +| Execution tree & task states | `tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Graph execution state (per execution) | `tangle sdk pipeline-runs graph-state EXECUTION_ID` | +| Container logs (application stack traces, code errors) | `tangle sdk pipeline-runs logs EXECUTION_ID` | | System events (eviction reasons, OOM kills, scheduling failures) | Launcher-native — NOT a Tangle command (see §7 and "Fetching System Events" below) | -| Search for runs | `uv run tangle sdk pipeline-runs search --name ` | -| Component spec (per-task) | `uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | -| Artifact metadata (URIs, size, hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | -| Export pipeline spec | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Search for runs | `tangle sdk pipeline-runs search --name ` | +| Component spec (per-task) | `tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | +| Artifact metadata (URIs, size, hash) | `tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Export pipeline spec | `tangle sdk pipeline-runs export RUN_ID --output output.yaml` | Artifact retrieval is **metadata-only** (`artifacts get` returns `{id, uri, size, hash}`); the `uri` is backend-agnostic — read the scheme, don't assume one. There @@ -42,16 +43,16 @@ in `OSS-CONVENTIONS.md` §5. ## Debugging Workflow -1. **Get failure details**: `uv run tangle sdk pipeline-runs status RUN_ID` for a quick - run + derived status summary, then `uv run tangle sdk pipeline-runs details RUN_ID +1. **Get failure details**: `tangle sdk pipeline-runs status RUN_ID` for a quick + run + derived status summary, then `tangle sdk pipeline-runs details RUN_ID --include-execution-state` — shows the execution tree with per-task status. Get execution IDs for failed tasks. For a single failed execution's graph state, - `uv run tangle sdk pipeline-runs graph-state EXECUTION_ID`. -2. **Inspect the failed task**: `uv run tangle sdk pipeline-runs details RUN_ID + `tangle sdk pipeline-runs graph-state EXECUTION_ID`. +2. **Inspect the failed task**: `tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` — drill into the specific failed execution to see the component spec as actually used. 3. **Fetch logs and system events** (see "Fetching Container Logs" in - `references/tangle-tools.md`): `uv run tangle sdk pipeline-runs logs EXECUTION_ID` + `references/tangle-tools.md`): `tangle sdk pipeline-runs logs EXECUTION_ID` for application logs (stack traces, code errors). Container logs are keyed by **EXECUTION_ID**, not run id. For system events (eviction, OOM, scheduling, `pods "task-…" not found` mysteries), the Tangle backend does **not** store @@ -62,12 +63,12 @@ in `OSS-CONVENTIONS.md` §5. auth wizard (`agents/auth-wizard.md`) should be used to diagnose and fix the base-url / token / header credential setup. 5. **Check upstream artifacts**: If logs mention missing data/inputs, check upstream - task outputs via `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` — an + task outputs via `tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` — an upstream task may have produced empty or wrong output. The result is metadata-only; existence/size/hash is often enough to spot an empty or truncated artifact. Only fetch bytes (signed-URL recipe, `OSS-CONVENTIONS.md` §5) when you genuinely need to inspect content. -6. **Export the pipeline**: `uv run tangle sdk pipeline-runs export RUN_ID --output +6. **Export the pipeline**: `tangle sdk pipeline-runs export RUN_ID --output /tmp/pipeline.yaml` to get the exact pipeline spec used. Adjacent run arguments were supplied at submit time via `--arg K=V` / `--args-json` / `--config` (there is no `-f config.yaml`). @@ -75,14 +76,14 @@ in `OSS-CONVENTIONS.md` §5. Modify the exported YAML, then resubmit. Hydration is the default; there is no `--dehydrate` step to run first and no `--no-wait` flag (submit never waits): ```bash - uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ --arg = ``` Submit returns immediately; to block on the result, use - `uv run tangle sdk pipeline-runs wait RUN_ID --max-wait N`. After submission, you may + `tangle sdk pipeline-runs wait RUN_ID --max-wait N`. After submission, you may annotate the run with generic provenance: ```bash - uv run tangle sdk pipeline-runs annotations set source tangle-cli + tangle sdk pipeline-runs annotations set source tangle-cli ``` ## Fetching System Events diff --git a/skills/tangent/agents/reporter.md b/skills/tangent/agents/reporter.md index 9fa96ac..c8a3f94 100644 --- a/skills/tangent/agents/reporter.md +++ b/skills/tangent/agents/reporter.md @@ -10,17 +10,17 @@ Generate an ML experiment report. Regenerate from scratch each round. ## Tools -**Always use the `tangle` CLI via Bash. Do NOT use any MCP tools.** -Run commands as `uv run tangle …` from a checkout of the `tangle-cli` repo. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …` (see [OSS-CONVENTIONS.md §1](../OSS-CONVENTIONS.md)). +**Always use the published `tangle` CLI via Bash. Do NOT use any MCP tools.** +Install persistently with `uv tool install tangle-cli`, or run one-off commands with `uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if intentionally validating a local `tangle-cli` checkout, prefix examples with `uv run` (see [OSS-CONVENTIONS.md §1](../OSS-CONVENTIONS.md)). -Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +Run `tangle quickstart` to discover available commands. Use `--help` on any command for detailed usage. | What you need | Command | |---|---| -| Artifact metadata (id, `uri`, size, hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Artifact metadata (id, `uri`, size, hash) | `tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | | Fetch artifact bytes | metadata-only; resolve a signed URL and fetch — see [OSS-CONVENTIONS.md §5](../OSS-CONVENTIONS.md) | -| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Run details | `tangle sdk pipeline-runs details RUN_ID --include-execution-state` | ## Inputs diff --git a/skills/tangent/agents/researcher.md b/skills/tangent/agents/researcher.md index ef35344..3bb6816 100644 --- a/skills/tangent/agents/researcher.md +++ b/skills/tangent/agents/researcher.md @@ -12,22 +12,22 @@ Think like an MLE who reads papers and asks "what if we tried this?" ## Tools -**Always use the `tangle` CLI via Bash. Do NOT use any MCP tool layer.** -Run commands as `uv run tangle …` from a checkout of the `tangle-cli` repo. For an installed CLI, prefer `uv tool install tangle-cli`; for one-off execution, use `uvx --from tangle-cli tangle …`. See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1. +**Always use the published `tangle` CLI via Bash. Do NOT use any MCP tool layer.** +Install persistently with `uv tool install tangle-cli`, or run one-off commands with `uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if intentionally validating a local `tangle-cli` checkout, prefix examples with `uv run`. See [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1. -Run `uv run tangle quickstart` to discover available commands. Use `--help` on any -command for detailed usage. For broader docs, see `uv run tangle sdk +Run `tangle quickstart` to discover available commands. Use `--help` on any +command for detailed usage. For broader docs, see `tangle sdk published-components library` and the public OSS docs at `github.com/TangleML/website/tree/master/docs`. | What you need | Command | |---|---| -| Export run as YAML | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | -| Inspect component | `uv run tangle sdk published-components inspect --name "Name" --full-spec` | -| Search components (optional, may be empty) | `uv run tangle sdk published-components search "keyword"` | -| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | -| Run status | `uv run tangle sdk pipeline-runs status RUN_ID` | -| Artifact metadata (URIs/size/hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Export run as YAML | `tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Inspect component | `tangle sdk published-components inspect --name "Name" --full-spec` | +| Search components (optional, may be empty) | `tangle sdk published-components search "keyword"` | +| Run details | `tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Run status | `tangle sdk pipeline-runs status RUN_ID` | +| Artifact metadata (URIs/size/hash) | `tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | | Fetch artifact bytes | metadata-only `get` → signed-URL recipe ([`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5) | | Recent commits | `git log --oneline --since="Nd" -- ` | | PRs/Issues | `gh pr list`/`gh issue list --repo --search ""` | @@ -111,7 +111,7 @@ Use `image_roots` to resolve modules to local files. To find source code for a published component, inspect it: ```bash -uv run tangle sdk published-components inspect --name "Component Name" --full-spec +tangle sdk published-components inspect --name "Component Name" --full-spec ``` The `annotations` section includes `component_yaml_path`, `git_relative_dir`, and `git_remote_url`. Use these to locate the YAML and source code in the repo. @@ -124,8 +124,8 @@ Use `--follow-deprecated` if the component is deprecated to find its successor. Tangle pipelines are nested subgraphs. Inputs flow through the hierarchy via `graphInput` wiring: top-level task output → subgraph input → nested subgraph input → leaf task argument. Trace the wiring at each level before modifying. -For pipeline and component schema details, use `uv run tangle sdk pipelines --help`, -`uv run tangle sdk published-components --help`, `uv run tangle sdk +For pipeline and component schema details, use `tangle sdk pipelines --help`, +`tangle sdk published-components --help`, `tangle sdk published-components library`, and the public OSS docs at `github.com/TangleML/website/tree/master/docs`. diff --git a/skills/tangent/agents/reviewer.md b/skills/tangent/agents/reviewer.md index 19d5732..4356fee 100644 --- a/skills/tangent/agents/reviewer.md +++ b/skills/tangent/agents/reviewer.md @@ -12,23 +12,24 @@ ML methodology issues. Be skeptical. Check the work. ## Tools -**Always use the `tangle` CLI via Bash.** Run commands as `uv run tangle …` -from a checkout of the `tangle-cli` repo. For an installed CLI, prefer -`uv tool install tangle-cli`; for one-off execution, use -`uvx --from tangle-cli tangle …`. See [OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) -§1 for the invocation rule and §4 for auth flags. - -Run `uv run tangle quickstart` to discover available commands. Use `--help` on any +**Always use the published `tangle` CLI via Bash.** Install persistently with +`uv tool install tangle-cli`, or run one-off commands with +`uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if +intentionally validating a local `tangle-cli` checkout, prefix examples with +`uv run`. See [OSS-CONVENTIONS.md](../OSS-CONVENTIONS.md) §1 for the invocation +rule and §4 for auth flags. + +Run `tangle quickstart` to discover available commands. Use `--help` on any command for detailed usage. | What you need | Command | |---|---| -| Run details | `uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state` | -| Drill into a task | `uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | -| Container logs | `uv run tangle sdk pipeline-runs logs EXECUTION_ID` | -| Artifact metadata (uri/size/hash) | `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | -| Export pipeline spec | `uv run tangle sdk pipeline-runs export RUN_ID --output output.yaml` | -| Inspect component | `uv run tangle sdk published-components inspect --name "Name" --full-spec` | +| Run details | `tangle sdk pipeline-runs details RUN_ID --include-execution-state` | +| Drill into a task | `tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID --include-implementations` | +| Container logs | `tangle sdk pipeline-runs logs EXECUTION_ID` | +| Artifact metadata (uri/size/hash) | `tangle sdk artifacts get RUN_ID -q '{"tasks": {...}}'` | +| Export pipeline spec | `tangle sdk pipeline-runs export RUN_ID --output output.yaml` | +| Inspect component | `tangle sdk published-components inspect --name "Name" --full-spec` | `artifacts get` is **metadata-only** — it returns `{id, uri, size, hash}` records; there is no `artifacts download`. Metadata is sufficient for review verification. diff --git a/skills/tangent/agents/scenario-builder.md b/skills/tangent/agents/scenario-builder.md index 50ce8e0..a5b8849 100644 --- a/skills/tangent/agents/scenario-builder.md +++ b/skills/tangent/agents/scenario-builder.md @@ -9,8 +9,7 @@ tools: read, write, bash, grep, glob, agent You create Tangent scenarios by interviewing the user. Your job is to ASK QUESTIONS and WAIT FOR ANSWERS — not to generate files immediately. -All commands run as `uv run tangle …` from a checkout of the `tangle-cli` repo (see -[`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). +Use the published `tangle` CLI via Bash: install persistently with `uv tool install tangle-cli`, or run one-off commands with `uvx --from tangle-cli tangle …`. Examples below use bare `tangle …`; if intentionally validating a local `tangle-cli` checkout, prefix examples with `uv run` (see [`../OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §1). ## CRITICAL RULES @@ -152,8 +151,8 @@ and introspect it to understand the pipeline structure. **If no source path but run ID was provided**, export it: ```bash -uv run tangle sdk pipeline-runs details # extract task names, pipeline structure -uv run tangle sdk pipeline-runs export --output /pipeline.yaml +tangle sdk pipeline-runs details # extract task names, pipeline structure +tangle sdk pipeline-runs export --output /pipeline.yaml ``` `export` writes the root spec as-is (omit `--output` to print to stdout). There is no `--dehydrate` flag — `--hydrate` on submit (the default) resolves the latest @@ -225,7 +224,7 @@ based on what fits — e.g., `metrics-guide.md`, `eval-metrics.md`, etc.). ## Phase 3: Code & Data **Introspect:** Trace pipeline tasks to source code using -`uv run tangle sdk published-components inspect --name --full-spec` +`tangle sdk published-components inspect --name --full-spec` (component discovery is **optional** and off by default in OSS — tolerate empty results and fall back to reading the component refs in `pipeline.yaml` directly). If the pipeline reads from a query-backed data source (a warehouse diff --git a/skills/tangent/references/data-sources.md b/skills/tangent/references/data-sources.md index 89ed00d..3e62aca 100644 --- a/skills/tangent/references/data-sources.md +++ b/skills/tangent/references/data-sources.md @@ -133,7 +133,7 @@ run's TTL or share with other scenarios. - **Read the promote task's logs.** The component prints `PROMOTED. data_source_id = ` near the end of its own task log — fetch - with `uv run tangle sdk pipeline-runs logs `. Simplest + with `tangle sdk pipeline-runs logs `. Simplest and works without modifying the pipeline. - **Wire it into a downstream task.** Connect the promote task's `data_source_id` output (a `String`) into another task that prints @@ -141,7 +141,7 @@ run's TTL or share with other scenarios. pipeline graph (e.g. a follow-up `Load data source` in the same run) rather than be recovered out-of-band. - Don't try to recover the value with `uv run tangle sdk artifacts get` — + Don't try to recover the value with `tangle sdk artifacts get` — that returns artifact *metadata* (`uri`/`size`/`hash`), not the contents of scalar `String` outputs. @@ -304,7 +304,7 @@ tasks: ``` ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg eval_set_id="" ``` @@ -313,7 +313,7 @@ uv run tangle sdk pipeline-runs submit pipeline.yaml \ - **No data-source components? Use the lightweight pattern.** If your backend doesn't register these components, you can still reuse a prior run's output: record the producer's `run_id` and the artifact `uri` - (read it scheme-agnostically from `uv run tangle sdk artifacts get RUN_ID`) + (read it scheme-agnostically from `tangle sdk artifacts get RUN_ID`) in `MEMORY.md`, and re-reference that `uri` directly. URIs are backend-agnostic and may look like `hf://datasets//@main/` — read the `uri` field; do not assume a scheme. diff --git a/skills/tangent/references/example-scenarios/01-mslr-ranking.md b/skills/tangent/references/example-scenarios/01-mslr-ranking.md index b7fb264..dc70c4a 100644 --- a/skills/tangent/references/example-scenarios/01-mslr-ranking.md +++ b/skills/tangent/references/example-scenarios/01-mslr-ranking.md @@ -4,7 +4,7 @@ A self-contained, public-data worked example of one autonomous tuning round. It loop in `references/step-0-initialize.md` … `references/step-7-decide.md`: read the situation, form a hypothesis, express it as a small config delta, submit, watch the right signals, and write down the outcome. Nothing here is specific to any one backend — every command is the -generic `uv run tangle …` surface from `references/tangle-tools.md`. +published `tangle …` surface from `references/tangle-tools.md`. ## Dataset (public) @@ -56,7 +56,7 @@ model" as out of scope here and just record the winning `run_id`. ### Situation -A baseline run finished. Reading its metrics (`uv run tangle sdk artifacts get -q +A baseline run finished. Reading its metrics (`tangle sdk artifacts get -q '{"tasks":["eval-ranker"]}'`, then fetching the metrics blob via the signed-URL recipe in `OSS-CONVENTIONS.md` §5): @@ -88,7 +88,7 @@ args under test. Run A — regularize: ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg num_leaves=63 \ --arg learning_rate=0.05 \ --arg min_data_in_leaf=200 \ @@ -102,7 +102,7 @@ Run B — align truncation to the metric (and a matching `n_estimators` bump to the lower learning rate): ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg lambdarank_truncation_level=10 \ --arg learning_rate=0.05 \ --arg n_estimators=2000 \ @@ -122,13 +122,13 @@ While the runs are in flight, watch status and graph state with the CLI rather t poll loop (`OSS-CONVENTIONS.md` §10, D14): ```bash -uv run tangle sdk pipeline-runs status -uv run tangle sdk pipeline-runs graph-state +tangle sdk pipeline-runs status +tangle sdk pipeline-runs graph-state ``` -To block until done: `uv run tangle sdk pipeline-runs wait --max-wait 600 +To block until done: `tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10`. Container logs (training progress, early-stopping rounds) come from -`uv run tangle sdk pipeline-runs logs `; scheduling/OOM events come from your +`tangle sdk pipeline-runs logs `; scheduling/OOM events come from your launcher's runtime, not from the Tangle backend (`OSS-CONVENTIONS.md` §7). When `eval-ranker` completes, read its metrics artifact and check, in order: diff --git a/skills/tangent/references/example-scenarios/02-text-classification.md b/skills/tangent/references/example-scenarios/02-text-classification.md index cb19d3d..bfec5a7 100644 --- a/skills/tangent/references/example-scenarios/02-text-classification.md +++ b/skills/tangent/references/example-scenarios/02-text-classification.md @@ -3,7 +3,7 @@ A second self-contained, public-data worked example — same loop, a different model family. It shows the situation → hypothesis → config-delta → signals → outcome rhythm applied to a transformer fine-tuning task instead of a gradient-boosted ranker. Every command is the -generic `uv run tangle …` surface (`references/tangle-tools.md`). +published `tangle …` surface (`references/tangle-tools.md`). ## Dataset (public) @@ -52,7 +52,7 @@ and move on. The baseline fine-tune finished but underwhelms, and the run was expensive: - macro-F1 = **0.918**, accuracy = **0.919**. -- Container logs (`uv run tangle sdk pipeline-runs logs `) show validation loss +- Container logs (`tangle sdk pipeline-runs logs `) show validation loss bottoming out around epoch 2 of 5 and creeping up after — the last 2–3 epochs are wasted compute and mild overfitting. - `max_seq_length=256`, but AG News items are short; most sequences are heavily padded. @@ -77,7 +77,7 @@ Args go inline at submit — no `-f config.yaml` (`OSS-CONVENTIONS.md` §10, D12 Run A — fewer epochs + early stopping: ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg num_train_epochs=3 \ --arg early_stopping_patience=1 \ --arg metric_for_best_model=macro_f1 \ @@ -90,7 +90,7 @@ uv run tangle sdk pipeline-runs submit pipeline.yaml \ Run B — right-size sequence length + warmup schedule: ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg max_seq_length=128 \ --arg lr_scheduler_type=linear \ --arg warmup_ratio=0.06 \ @@ -109,8 +109,8 @@ record it, then watch the run. Use the CLI for status, not a Python poll loop (`OSS-CONVENTIONS.md` §10, D14): ```bash -uv run tangle sdk pipeline-runs status -uv run tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10 +tangle sdk pipeline-runs status +tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10 ``` Application progress (loss curves, early-stop trigger, steps/sec) is in container logs; a diff --git a/skills/tangent/references/example-scenarios/INDEX.md b/skills/tangent/references/example-scenarios/INDEX.md index 21cab7d..8171df1 100644 --- a/skills/tangent/references/example-scenarios/INDEX.md +++ b/skills/tangent/references/example-scenarios/INDEX.md @@ -6,7 +6,7 @@ > a single hypothesis, express it as a small config delta, submit, watch the right signals, > and write down the outcome. -Every command in these scenarios is the generic `uv run tangle …` surface documented in +Every command in these scenarios uses the published `tangle …` surface documented in `references/tangle-tools.md`. They name only public datasets and public models, store artifacts scheme-agnostically (read the `uri`; see `OSS-CONVENTIONS.md` §5), and treat registry / promotion / scheduling / `--run-as` as **extension-only** (`OSS-CONVENTIONS.md` diff --git a/skills/tangent/references/iterating-on-runs.md b/skills/tangent/references/iterating-on-runs.md index 8d56487..269cec0 100644 --- a/skills/tangent/references/iterating-on-runs.md +++ b/skills/tangent/references/iterating-on-runs.md @@ -4,7 +4,7 @@ When modifying and re-running an existing pipeline (e.g. change params for faile 1. **Export the run**: ```bash - uv run tangle sdk pipeline-runs export --output /tmp/pipeline.yaml + tangle sdk pipeline-runs export --output /tmp/pipeline.yaml ``` This writes the run's root pipeline spec to `/tmp/pipeline.yaml` (omit `--output` to print to stdout). The export is the spec **as-is** — there is no `--dehydrate` flag and no @@ -13,17 +13,17 @@ When modifying and re-running an existing pipeline (e.g. change params for faile 2. **Inspect execution statuses**: ```bash - uv run tangle sdk pipeline-runs details --include-execution-state + tangle sdk pipeline-runs details --include-execution-state ``` to identify failed/cancelled/skipped executions. For a quick run + derived status - summary, use `uv run tangle sdk pipeline-runs status `. + summary, use `tangle sdk pipeline-runs status `. 3. **Understand the YAML structure**: Tangle pipelines are nested subgraphs. Inputs flow through the hierarchy via `graphInput` wiring: top-level task output → subgraph input → nested subgraph input → leaf task argument. Trace the wiring at each level before - modifying. For pipeline and component schema details, use `uv run tangle sdk pipelines - --help` / `uv run tangle sdk components --help`, browse the curated standard library - (`uv run tangle sdk published-components library`), and consult the public docs at + modifying. For pipeline and component schema details, use `tangle sdk pipelines + --help` / `tangle sdk components --help`, browse the curated standard library + (`tangle sdk published-components library`), and consult the public docs at `github.com/TangleML/website/tree/master/docs`. 4. **Modify the pipeline**: Edit the exported YAML directly. To replace a component, swap @@ -32,17 +32,17 @@ When modifying and re-running an existing pipeline (e.g. change params for faile 5. **Preview**: render the structure before submitting — ```bash - uv run tangle sdk pipelines diagram /tmp/pipeline.yaml # Mermaid - uv run tangle sdk pipelines layout /tmp/pipeline.yaml # auto-layout + tangle sdk pipelines diagram /tmp/pipeline.yaml # Mermaid + tangle sdk pipelines layout /tmp/pipeline.yaml # auto-layout ``` and validate it parses: ```bash - uv run tangle sdk pipelines validate /tmp/pipeline.yaml + tangle sdk pipelines validate /tmp/pipeline.yaml ``` 6. **Submit** (see Submission Rules in `references/tangle-tools.md`): ```bash - uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ --arg = \ --annotation session= --annotation round= ``` @@ -52,5 +52,5 @@ When modifying and re-running an existing pipeline (e.g. change params for faile file for CLI-option defaults (base-url/auth/log-type — not run args). `submit` never waits; to block on completion, follow up with: ```bash - uv run tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10 + tangle sdk pipeline-runs wait --max-wait 600 --poll-interval 10 ``` diff --git a/skills/tangent/references/secrets.md b/skills/tangent/references/secrets.md index 0752e90..d59a329 100644 --- a/skills/tangent/references/secrets.md +++ b/skills/tangent/references/secrets.md @@ -23,7 +23,7 @@ token, etc.): via `dynamicData.secret.name` in the pipeline YAML; Tangle resolves the value at task-launch time, inside the container, with no plaintext on the pipeline spec. -3. **The human creates / rotates the secret value**, via `uv run tangle sdk +3. **The human creates / rotates the secret value**, via `tangle sdk secrets create ... --from-env VAR_NAME` (preferred — agent never touches the value). The agent's job is to identify *that a secret is needed*, *what to name it*, and *how to wire it through the pipeline*. Not to source the @@ -124,7 +124,7 @@ If an input/argument is described as, named like, or behaves like any of: ### 1. Discover what secrets exist ```bash -uv run tangle sdk secrets list +tangle sdk secrets list ``` Output lists `secret_name`, `updated_at`, optional `expires_at`, optional @@ -150,7 +150,7 @@ never stored in history): # enters shell history (only `read -rs MY_API_KEY` is recorded, not the value). read -rs MY_API_KEY # paste the value, press Enter (no echo) export MY_API_KEY -uv run tangle sdk secrets create MY_API_KEY \ +tangle sdk secrets create MY_API_KEY \ --from-env MY_API_KEY \ --description 'Used by for ' unset MY_API_KEY # clear it from the current shell @@ -177,10 +177,10 @@ configs: ``` ```bash -uv run tangle sdk secrets create --config secrets_config.yaml +tangle sdk secrets create --config secrets_config.yaml ``` -**Do NOT** propose `uv run tangle sdk secrets create NAME --value 'sk-…'` with +**Do NOT** propose `tangle sdk secrets create NAME --value 'sk-…'` with a value you pulled from somewhere. The `--value` / `-v` flag exists for humans typing at a prompt, not for agents shuffling credentials between systems. Prefer `--from-env` / `-e` everywhere. @@ -192,7 +192,7 @@ credential — or how it expects to receive it — inspect the component's schem first rather than guessing: ```bash -uv run tangle sdk published-components inspect "" +tangle sdk published-components inspect "" ``` That shows each input and its type, so you can tell which argument is @@ -222,7 +222,7 @@ tasks: Things to verify after wiring: -- `uv run tangle sdk pipelines validate ` passes. +- `tangle sdk pipelines validate ` passes. - Run the **complete 4-stage pre-submit gate** from [`step-3-submit.md`](step-3-submit.md) § "Pre-submit checks". The gate uses `grep -lEi` (filenames only, never echoes matching lines) plus a @@ -307,15 +307,15 @@ A minimal end-to-end demo against a public bearer-token echo endpoint # Use --from-env so the value never lands in shell history. read -rs DEMO_BEARER_TOKEN export DEMO_BEARER_TOKEN -uv run tangle sdk secrets create DEMO_BEARER_TOKEN \ +tangle sdk secrets create DEMO_BEARER_TOKEN \ --from-env DEMO_BEARER_TOKEN --description 'demo' unset DEMO_BEARER_TOKEN # 2. Submit — the pipeline references DEMO_BEARER_TOKEN via dynamicData.secret -uv run tangle sdk pipeline-runs submit secrets_demo_pipeline.yaml --hydrate +tangle sdk pipeline-runs submit secrets_demo_pipeline.yaml --hydrate # 3. Cleanup -uv run tangle sdk secrets delete DEMO_BEARER_TOKEN +tangle sdk secrets delete DEMO_BEARER_TOKEN ``` The pipeline injects the secret as `Authorization: Bearer ` to @@ -326,12 +326,12 @@ shape. ## CLI reference ```bash -uv run tangle sdk secrets list -uv run tangle sdk secrets create NAME --from-env ENV_VAR \ +tangle sdk secrets list +tangle sdk secrets create NAME --from-env ENV_VAR \ [--description '…'] [--expires-at 2026-12-31T00:00:00Z] -uv run tangle sdk secrets update NAME --from-env ENV_VAR -uv run tangle sdk secrets delete NAME [--force] -uv run tangle sdk secrets --help +tangle sdk secrets update NAME --from-env ENV_VAR +tangle sdk secrets delete NAME [--force] +tangle sdk secrets --help ``` `create` / `update` take `--value` / `-v` or (preferred) `--from-env` / `-e`, @@ -342,6 +342,6 @@ files (see the `_defaults` / `configs` block above). ## When in doubt Stop and ask the human. "This pipeline needs an `X_API_KEY` — please create -a Tangle secret named `` (`uv run tangle sdk secrets create +a Tangle secret named `` (`tangle sdk secrets create --from-env `) and confirm before I wire it through" is always the right move. Never the wrong move. diff --git a/skills/tangent/references/setup.md b/skills/tangent/references/setup.md index e6284a0..d414245 100644 --- a/skills/tangent/references/setup.md +++ b/skills/tangent/references/setup.md @@ -10,32 +10,30 @@ cross-references resolve directly on disk. ## 1. Install / run the CLI -From a checkout, run commands through `uv run`: - -```bash -uv run tangle quickstart -uv run tangle --help -uv run tangle sdk --help -uv run tangle api --help -``` - -For a persistent user-level CLI install, prefer uv tools: +For normal Tangent usage, install the published CLI as a persistent uv tool: ```bash uv tool install tangle-cli tangle quickstart -tangle-cli --help +tangle --help +tangle sdk --help +tangle api --help ``` For one-off execution without a persistent install, use `uvx`: ```bash uvx --from tangle-cli tangle --help +uvx --from tangle-cli tangle quickstart ``` Generic Python environments may also use `pip install tangle-cli`; use `uv pip install tangle-cli` only inside an explicitly managed virtualenv. +When intentionally validating a local checkout of the `tangle-cli` repo, prefix +examples with `uv run` (for example, `uv run tangle quickstart`). Skill examples +otherwise use the installed-tool form (`tangle …` / `tangle-cli …`). + `uv` resolves dependencies against public PyPI. In the `tangle-cli` workspace, `uv` installs the workspace `tangle-api` package automatically for dev/tests. The published default `tangle-cli` install includes `tangle-api`, enabling static @@ -44,7 +42,7 @@ API-backed commands and the handwritten `TangleApiClient` wrapper. The old Then discover available commands: ```bash -uv run tangle quickstart +tangle quickstart ``` Help is standard `--help` (there is no `--help-extended` / `--help-full`). @@ -81,7 +79,7 @@ export TANGLE_API_HEADERS='X-Gateway-Auth: …' Or pass them per-command: ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --base-url https://api.example \ --auth-header 'Bearer …' \ -H 'X-Gateway-Auth: …' \ @@ -99,7 +97,7 @@ Run a cheap, read-only call. If it returns (even with zero results), your backen URL and credentials are wired correctly: ```bash -uv run tangle sdk pipeline-runs search --limit 1 +tangle sdk pipeline-runs search --limit 1 ``` Interpreting failures: @@ -117,11 +115,11 @@ The unified CLI is `tangle`, split into `tangle sdk …` (hand-written SDK/local/compound commands) and `tangle api …` (auto-generated API wrappers): ```bash -uv run tangle quickstart -uv run tangle sdk pipeline-runs submit pipeline.yaml --arg key=value -uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state -uv run tangle sdk pipeline-runs logs EXECUTION_ID -uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {"TaskName": ["output"]}}' +tangle quickstart +tangle sdk pipeline-runs submit pipeline.yaml --arg key=value +tangle sdk pipeline-runs details RUN_ID --include-execution-state +tangle sdk pipeline-runs logs EXECUTION_ID +tangle sdk artifacts get RUN_ID -q '{"tasks": {"TaskName": ["output"]}}' ``` For checking run status, see [`tangle-tools.md`](tangle-tools.md) — prefer the @@ -129,8 +127,8 @@ light status summary (`tangle sdk pipeline-runs status RUN_ID`) and graph-state (`tangle sdk pipeline-runs graph-state EXECUTION_ID`) for polling over the heavy `details … --include-execution-state` payload. -**Do not memorize a static command list.** Run `uv run tangle quickstart` to -discover commands, and `uv run tangle sdk --help` for detailed usage. +**Do not memorize a static command list.** Run `tangle quickstart` to +discover commands, and `tangle sdk --help` for detailed usage. ## 5. Artifacts @@ -140,7 +138,7 @@ backend it is a HuggingFace `hf://…` URI), so read the `uri` field rather than assuming a scheme: ```bash -uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {"TaskName": ["output"]}}' +tangle sdk artifacts get RUN_ID -q '{"tasks": {"TaskName": ["output"]}}' ``` `artifacts get` is **metadata-only** — there is no download-to-disk command. To diff --git a/skills/tangent/references/step-0-initialize.md b/skills/tangent/references/step-0-initialize.md index 6bb9f8d..be39920 100644 --- a/skills/tangent/references/step-0-initialize.md +++ b/skills/tangent/references/step-0-initialize.md @@ -4,17 +4,16 @@ Pure setup — load everything before the experiment loop. No analysis, no decis ## Set up the `tangle` CLI -Before anything else, make sure you can invoke the CLI. The skills drive the OSS -core, run from a checkout of the `tangle-cli` repo: +Before anything else, make sure you can invoke the published CLI: ```bash -uv run tangle --help +tangle --help ``` -If that fails, work through `references/setup.md` first (checkout, `uv tool install tangle-cli`, or `uvx --from tangle-cli tangle …`; base-url/auth). Once `uv run tangle --help` or installed `tangle --help` works, discover the available commands: +If that fails, work through `references/setup.md` first (`uv tool install tangle-cli`, `uvx --from tangle-cli tangle …`, or local checkout validation with `uv run`; base-url/auth). Once `tangle --help` works, discover the available commands: ```bash -uv run tangle quickstart +tangle quickstart ``` > For an installed CLI, prefer `uv tool install tangle-cli` and invoke `tangle …` or `tangle-cli …`; for one-off execution, use `uvx --from tangle-cli tangle …`. @@ -27,10 +26,10 @@ All experiment state lives in the scenario directory. Set the absolute path: SCENARIO_DIR= ``` -From a checkout, all `tangle` commands run via `uv run tangle …`. From an -installed CLI, use `tangle …` / `tangle-cli …`; for one-off execution, use -`uvx --from tangle-cli tangle …`. All file reads/writes use absolute -`SCENARIO_DIR` paths. These are different locations — don't confuse them. +All normal commands use the published CLI (`tangle …` / `tangle-cli …`) or +one-off `uvx --from tangle-cli tangle …`. When intentionally validating a local +`tangle-cli` checkout, prefix examples with `uv run`. All file reads/writes use +absolute `SCENARIO_DIR` paths. These are different locations — don't confuse them. ## No Scenario Yet? Build One @@ -58,8 +57,8 @@ below. ### Resume: Check Active Runs If MEMORY.md "Active Runs" lists runs from a prior session, light-poll each with -the CLI: `uv run tangle sdk pipeline-runs status RUN_ID` (run + derived status -summary), and `uv run tangle sdk pipeline-runs graph-state EXECUTION_ID` for the +the CLI: `tangle sdk pipeline-runs status RUN_ID` (run + derived status +summary), and `tangle sdk pipeline-runs graph-state EXECUTION_ID` for the per-task graph state. Classify: all tasks terminal → Step 5, any RUNNING → Step 4, any FAILED → Step 4 (debugger). This replaces Step 1-3 when resuming. @@ -79,7 +78,7 @@ the latest published component versions when the refs are `name:`/`digest:` only **Option B: Export from baseline run** If no source pipeline exists, export the root spec from the baseline run: ```bash -uv run tangle sdk pipeline-runs export BASELINE_RUN_ID --output $SCENARIO_DIR/pipeline.yaml +tangle sdk pipeline-runs export BASELINE_RUN_ID --output $SCENARIO_DIR/pipeline.yaml ``` `export` writes the root spec as-is. There is no `--dehydrate` flag: if the exported YAML carries full inline `spec:` blocks, `--hydrate` on submit is a no-op @@ -94,8 +93,8 @@ submitting. 3. Initialize `$SCENARIO_DIR/logs/events.jsonl` (create if it doesn't exist) ## Gate — do NOT proceed to Step 1 until all pass: -- [ ] `uv run tangle --help` works (CLI reachable; see `references/setup.md` if not) -- [ ] `uv run tangle quickstart` ran successfully +- [ ] `tangle --help` works via the published CLI (or `uvx --from tangle-cli tangle --help` for one-off execution) +- [ ] `tangle quickstart` ran successfully - [ ] `SCENARIO_DIR` set to absolute path - [ ] `scenario.yaml` read and understood - [ ] `MEMORY.md` read diff --git a/skills/tangent/references/step-3-submit.md b/skills/tangent/references/step-3-submit.md index 3034d94..1a948a2 100644 --- a/skills/tangent/references/step-3-submit.md +++ b/skills/tangent/references/step-3-submit.md @@ -28,7 +28,7 @@ Auto-loop bookkeeping rides along as generic `--annotation K=V` pairs: ```bash # experiment args + auto-loop annotations, assembled on the submit line -uv run tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ +tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ --arg = \ --annotation session=YYYY-MM-DD- \ --annotation round= \ @@ -77,8 +77,8 @@ fi # in the pipeline must exist under the authenticating identity. A typoed or # not-yet-created name passes step 1 (it's not a credential body) but fails # at runtime, which wastes budget and confuses the user. -# Run under `uv run` so the project env (incl. PyYAML) is available — bare -# python3 may not have PyYAML, per the bundle's uv-run rule (OSS-CONVENTIONS §1). +# Run the helper in a Python environment with PyYAML available. `uv run` is shown +# for scenario repos that manage dependencies with uv; this is not a Tangle CLI invocation. uv run python3 - <<'PY' "$SCENARIO_DIR/pipeline.yaml" import sys, yaml, subprocess, json path = sys.argv[1] @@ -106,7 +106,7 @@ if missing: sys.stderr.write( "ERROR: pipeline references Tangle secrets that don't exist under the " "authenticating identity: " + ", ".join(missing) + "\n" - " Ask the human to create them via: uv run tangle sdk secrets create --from-env \n" + " Ask the human to create them via: tangle sdk secrets create --from-env \n" " (see references/secrets.md § 'If the secret is missing, ask the human to create it').\n" ) sys.exit(1) @@ -127,7 +127,7 @@ stop, surface the failure to the human, and do not submit. ### Submit (only after every pre-submit check above exited cleanly) ```bash -uv run tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ +tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ --arg = \ --annotation session=YYYY-MM-DD- \ --annotation round= \ @@ -137,11 +137,11 @@ uv run tangle sdk pipeline-runs submit $SCENARIO_DIR/pipeline.yaml \ Hydration is the default, so component versions are resolved as part of submit. `submit` returns as soon as the run is created — it does **not** wait. To block -on completion, follow up with `uv run tangle sdk pipeline-runs wait RUN_ID`. +on completion, follow up with `tangle sdk pipeline-runs wait RUN_ID`. ### If you modified component source code: 1. Rebuild from your Python source, pointing at the image you built and pushed - yourself: `uv run tangle sdk components generate from-python source.py --image --output component.yaml` + yourself: `tangle sdk components generate from-python source.py --image --output component.yaml` 2. Update ref in pipeline YAML: swap `digest: ...` with `url: file://` 3. Submit with the command above @@ -149,14 +149,14 @@ See `agents/builder.md` for the full workflow. ## Post-Submission -`uv run tangle sdk pipeline-runs submit` returns a **`run_id`** for each submitted +`tangle sdk pipeline-runs submit` returns a **`run_id`** for each submitted pipeline. Treat `run_id` as a first-class session concept: 1. Log to `sessions/YYYY-MM-DD.md` — record the `run_id` in a `## Run Log` section with timestamp, round, label, config diff. Order is chronological. 2. **Write to MEMORY.md "Active Runs" immediately** — `run_id`, run link (`/runs/`, or inspect via - `uv run tangle sdk pipeline-runs details RUN_ID`), label, config summary, + `tangle sdk pipeline-runs details RUN_ID`), label, config summary, timestamp. Survives session interruptions. 3. The `run_id` is what keys learnings records in Step 7 (`learning-.json` under `$LEARNINGS_DIR//`), so make sure diff --git a/skills/tangent/references/step-4-monitor.md b/skills/tangent/references/step-4-monitor.md index 6d8502e..7a12d3e 100644 --- a/skills/tangent/references/step-4-monitor.md +++ b/skills/tangent/references/step-4-monitor.md @@ -29,19 +29,19 @@ and graph state, the CLI commands below are the supported path). Light status for a single run (run + derived status summary): ```bash -uv run tangle sdk pipeline-runs status RUN_ID +tangle sdk pipeline-runs status RUN_ID ``` Graph execution state (per-task status counts; takes an EXECUTION_ID): ```bash -uv run tangle sdk pipeline-runs graph-state EXECUTION_ID +tangle sdk pipeline-runs graph-state EXECUTION_ID ``` For multiple runs, call `status` once per run id: ```bash for rid in RUN_1 RUN_2 RUN_3; do echo "$rid:" - uv run tangle sdk pipeline-runs status "$rid" + tangle sdk pipeline-runs status "$rid" done ``` @@ -54,7 +54,7 @@ Mark runs exceeding 2x `scenario.timing.total_seconds` as STUCK and replace. When a run completes (SUCCEEDED or FAILED), immediately run: ```bash -uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state +tangle sdk pipeline-runs details RUN_ID --include-execution-state ``` This returns the execution tree with per-component status. Check for: @@ -99,14 +99,14 @@ Act on the diagnosis: Only cancel when the user asks or a run is blocking resources. ```bash -uv run tangle sdk pipeline-runs cancel RUN_ID +tangle sdk pipeline-runs cancel RUN_ID ``` ## Waiting Use `dispatch` for non-blocking wait (if available), or sleep between light polls: ``` -dispatch({ command: "uv run tangle sdk pipeline-runs wait --max-wait --poll-interval 10 --exit-on-first-failure" }) +dispatch({ command: "tangle sdk pipeline-runs wait --max-wait --poll-interval 10 --exit-on-first-failure" }) ``` Interval: 180s (short pipelines), 600s (medium), 900s (long). `wait` defaults to `--max-wait 600` and `--poll-interval 10` if you omit them. diff --git a/skills/tangent/references/step-5-evaluate.md b/skills/tangent/references/step-5-evaluate.md index 6fdd180..d472c55 100644 --- a/skills/tangent/references/step-5-evaluate.md +++ b/skills/tangent/references/step-5-evaluate.md @@ -5,7 +5,7 @@ Two parts: collect metrics, then analyze. ## Part 1: Collect Metrics For each completed run: -1. Get artifact metadata for the eval task with `uv run tangle sdk artifacts get RUN_ID -q '{"tasks": {"": [""]}}'`. This returns metadata records (`{id, uri, size, hash}`) — it does **not** download bytes. Read the `uri` scheme-agnostically (under the OSS backend it is typically `hf://…`); never assume a particular storage scheme. To read the metric JSON contents, follow the signed-URL fetch recipe in [`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5: `artifacts get` → `tangle api artifacts signed-artifact-url` → fetch with `curl -L` (or `huggingface_hub` for `hf://` URIs). +1. Get artifact metadata for the eval task with `tangle sdk artifacts get RUN_ID -q '{"tasks": {"": [""]}}'`. This returns metadata records (`{id, uri, size, hash}`) — it does **not** download bytes. Read the `uri` scheme-agnostically (under the OSS backend it is typically `hf://…`); never assume a particular storage scheme. To read the metric JSON contents, follow the signed-URL fetch recipe in [`OSS-CONVENTIONS.md`](../OSS-CONVENTIONS.md) §5: `artifacts get` → `tangle api artifacts signed-artifact-url` → fetch with `curl -L` (or `huggingface_hub` for `hf://` URIs). 2. Extract target metric (`scenario.metrics.target.path`) and common metrics 3. Check guard metrics (`scenario.metrics.guards`) 4. Remove from MEMORY.md "Active Runs" @@ -19,7 +19,7 @@ Present results sorted by target metric. Include all common metrics. For the best run and any anomalous results, fetch container logs from the training component: ```bash -uv run tangle sdk pipeline-runs logs EXECUTION_ID +tangle sdk pipeline-runs logs EXECUTION_ID ``` Use the execution_id recorded in Step 4 (logs are keyed by EXECUTION_ID, not run id). Look for: - Training convergence (loss progression) diff --git a/skills/tangent/references/tangle-tools.md b/skills/tangent/references/tangle-tools.md index 308db35..4873668 100644 --- a/skills/tangent/references/tangle-tools.md +++ b/skills/tangent/references/tangle-tools.md @@ -14,38 +14,36 @@ with new commands and flags — discover the current surface with `--help` and ## Install / Invoke -From a checkout, run commands as `uv run tangle …`: - -```bash -uv run tangle quickstart -uv run tangle --help -uv run tangle sdk --help -uv run tangle api --help -``` - -For a persistent user-level CLI install, prefer uv tools: +For normal Tangent usage, install the published CLI as a persistent uv tool: ```bash uv tool install tangle-cli tangle quickstart -tangle-cli --help +tangle --help +tangle sdk --help +tangle api --help ``` For one-off execution without a persistent install, use `uvx`: ```bash uvx --from tangle-cli tangle --help +uvx --from tangle-cli tangle quickstart ``` Generic Python environments may also use `pip install tangle-cli`; use `uv pip install tangle-cli` only inside an explicitly managed virtualenv. +When intentionally validating a local checkout of the `tangle-cli` repo, prefix +examples with `uv run` (for example, `uv run tangle quickstart`). Skill examples +otherwise use the installed-tool form (`tangle …` / `tangle-cli …`). + The default `tangle-cli` install includes `tangle-api` and enables static API-backed commands plus the handwritten `TangleApiClient` (see [Programmatic client](#programmatic-client-python)). In the `tangle-cli` workspace, `uv` installs the workspace `tangle-api` package automatically for dev/tests. The old `native` extra is a compatibility/no-op alias. ## Discover Available Commands ```bash -uv run tangle quickstart +tangle quickstart ``` This prints static onboarding text. Use it at the start of every session to learn @@ -57,12 +55,12 @@ Help is standard cyclopts `--help` — there is no `--help-extended` / `--help-full`: ```bash -uv run tangle sdk --help -uv run tangle api --help +tangle sdk --help +tangle api --help ``` For broader docs, point at `tangle sdk --help`, the curated standard -library (`uv run tangle sdk published-components library`), and the public OSS +library (`tangle sdk published-components library`), and the public OSS docs at `github.com/TangleML/website/tree/master/docs`. ## Running Commands @@ -72,8 +70,8 @@ wrappers) and `tangle sdk …` (hand-written SDK / local / compound commands). Most workflow commands live under `tangle sdk`: ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml --arg shop=acme --annotation session=2026-06-23-ranking -uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state +tangle sdk pipeline-runs submit pipeline.yaml --arg shop=acme --annotation session=2026-06-23-ranking +tangle sdk pipeline-runs details RUN_ID --include-execution-state ``` Auth flags (see [Auth & environment](#auth--environment)) attach to any @@ -104,10 +102,10 @@ There is **no** `-f config.yaml`. Pass pipeline run arguments with repeatable file is preferred): ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg model=baseline --arg epochs=3 -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --args-json '{"model": "baseline", "epochs": 3}' ``` @@ -128,7 +126,7 @@ searchable; all are optional: | `source` | `tangle-cli` | Optional provenance marker (only if you want it) | ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg model=baseline \ --annotation session=2026-06-23-ranking \ --annotation round=1 \ @@ -139,15 +137,15 @@ uv run tangle sdk pipeline-runs submit pipeline.yaml \ Or set after submission: ```bash -uv run tangle sdk pipeline-runs annotations set RUN_ID session 2026-06-23-ranking -uv run tangle sdk pipeline-runs annotations list RUN_ID -uv run tangle sdk pipeline-runs annotations delete RUN_ID round +tangle sdk pipeline-runs annotations set RUN_ID session 2026-06-23-ranking +tangle sdk pipeline-runs annotations list RUN_ID +tangle sdk pipeline-runs annotations delete RUN_ID round ``` ### Preview without submitting ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run +tangle sdk pipeline-runs submit pipeline.yaml --dry-run ``` `--dry-run` prints the submit body and creates no run. @@ -155,7 +153,7 @@ uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run ### Canonical submit command ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --arg K=V --annotation session=YYYY-MM-DD-scenario ``` @@ -164,10 +162,10 @@ uv run tangle sdk pipeline-runs submit pipeline.yaml \ Local pipeline operations live under `pipelines` (NOT `pipeline-runs`): ```bash -uv run tangle sdk pipelines validate pipeline.yaml -uv run tangle sdk pipelines layout pipeline.yaml [--recursive] [-o out.yaml] -uv run tangle sdk pipelines hydrate template.yaml -o out.yaml [--var K=V] -uv run tangle sdk pipelines diagram pipeline.yaml # Mermaid (no GUI viewer) +tangle sdk pipelines validate pipeline.yaml +tangle sdk pipelines layout pipeline.yaml [--recursive] [-o out.yaml] +tangle sdk pipelines hydrate template.yaml -o out.yaml [--var K=V] +tangle sdk pipelines diagram pipeline.yaml # Mermaid (no GUI viewer) ``` There is no `dehydrate` command or `--dehydrate` flag in OSS. To iterate on an @@ -180,18 +178,18 @@ the default) — see [`references/iterating-on-runs.md`](iterating-on-runs.md). hand-rolled Python loop: ```bash -uv run tangle sdk pipeline-runs status RUN_ID # run + derived status summary -uv run tangle sdk pipeline-runs graph-state EXECUTION_ID # graph execution state +tangle sdk pipeline-runs status RUN_ID # run + derived status summary +tangle sdk pipeline-runs graph-state EXECUTION_ID # graph execution state ``` **Heavy** — use only after completion, for debugging or extracting `execution_id`s: ```bash -uv run tangle sdk pipeline-runs details RUN_ID --include-execution-state -uv run tangle sdk pipeline-runs details RUN_ID --include-implementations -uv run tangle sdk pipeline-runs details RUN_ID --include-annotations -uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID +tangle sdk pipeline-runs details RUN_ID --include-execution-state +tangle sdk pipeline-runs details RUN_ID --include-implementations +tangle sdk pipeline-runs details RUN_ID --include-annotations +tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID ``` ## Waiting / Polling @@ -199,7 +197,7 @@ uv run tangle sdk pipeline-runs details RUN_ID --execution-id EXEC_ID To block until a run completes (bounded): ```bash -uv run tangle sdk pipeline-runs wait RUN_ID \ +tangle sdk pipeline-runs wait RUN_ID \ --max-wait 600 --poll-interval 10 [--exit-on-first-failure] ``` @@ -208,7 +206,7 @@ Defaults: `--max-wait` 600s, `--poll-interval` 10s. ## Searching Runs ```bash -uv run tangle sdk pipeline-runs search --name NAME \ +tangle sdk pipeline-runs search --name NAME \ [--created-by USER] [--annotation K=V] \ [--start-date DATE] [--end-date DATE] [--limit N] [--query JSON] [QUERY] ``` @@ -216,7 +214,7 @@ uv run tangle sdk pipeline-runs search --name NAME \ ## Exporting a Run ```bash -uv run tangle sdk pipeline-runs export RUN_ID --output out.yaml # omit --output to print to stdout +tangle sdk pipeline-runs export RUN_ID --output out.yaml # omit --output to print to stdout ``` Exports the root spec as-is; there is no `--dehydrate`. @@ -226,7 +224,7 @@ Exports the root spec as-is; there is no `--dehydrate`. For application logs (stack traces, code errors), keyed by **EXECUTION_ID**: ```bash -uv run tangle sdk pipeline-runs logs EXECUTION_ID +tangle sdk pipeline-runs logs EXECUTION_ID ``` This backend-native container log surface is the **only** log surface the Tangle @@ -259,7 +257,7 @@ diagnosis, lean on container logs plus your launcher's native events — see `hf://datasets//@/`. ```bash -uv run tangle sdk artifacts get RUN_ID -q '{"artifact_ids":[""]}' +tangle sdk artifacts get RUN_ID -q '{"artifact_ids":[""]}' ``` `-q`/`--query` is a JSON string with optional keys `tasks`, `components`, @@ -271,7 +269,7 @@ standard recipe — see [`OSS-CONVENTIONS.md` §5](../OSS-CONVENTIONS.md)): 1. Get metadata and read the `uri` (above). 2. Ask the backend for a signed URL: ```bash - uv run tangle api artifacts signed-artifact-url --id + tangle api artifacts signed-artifact-url --id ``` 3. Fetch with a generic client — `curl -L "" -o ./out`, or for `hf://` URIs `huggingface_hub` (`hf_hub_download` / `snapshot_download`). @@ -286,12 +284,12 @@ Local authoring lives under `components`; the registry lives under ```bash # Generate a component from a Python source file (user-built image) -uv run tangle sdk components generate from-python source.py \ +tangle sdk components generate from-python source.py \ [--function NAME] [--image REG/IMG:TAG] [--output OUT] [--name NAME] \ [--mode inline|bundle] [--dependencies-from REQ] [--strip-code] [--resolve-root DIR] # Bump a local component's version -uv run tangle sdk components bump-version component.yaml [--set-version V] [--update-timestamp] +tangle sdk components bump-version component.yaml [--set-version V] [--update-timestamp] ``` There is no image build/push in the CLI. Build and push the image yourself (your @@ -301,22 +299,22 @@ own docker/podman), then pass `--image ` to ```bash # Publish to the registry -uv run tangle sdk published-components publish component.yaml \ +tangle sdk published-components publish component.yaml \ [--image …] [--name …] [--description …] [--annotations JSON] [--dry-run] [--published-by …] # Inspect (exactly one of NAME or --digest) -uv run tangle sdk published-components inspect --name NAME --full-spec -uv run tangle sdk published-components inspect --digest DIGEST +tangle sdk published-components inspect --name NAME --full-spec +tangle sdk published-components inspect --digest DIGEST # Search — keyword/name/digest only -uv run tangle sdk published-components search NAME \ +tangle sdk published-components search NAME \ [--digest D] [--published-by U] [--include-deprecated] # Deprecate -uv run tangle sdk published-components deprecate DIGEST [--superseded-by DIGEST] +tangle sdk published-components deprecate DIGEST [--superseded-by DIGEST] # Curated standard library -uv run tangle sdk published-components library +tangle sdk published-components library ``` To publish many components at once, pass a `--config` YAML/JSON list (or @@ -331,10 +329,10 @@ fresh install — never a hard prerequisite of a workflow step. ## Secrets ```bash -uv run tangle sdk secrets list -uv run tangle sdk secrets create NAME --from-env ENVVAR [-d DESCRIPTION] [--expires-at WHEN] -uv run tangle sdk secrets update NAME --from-env ENVVAR -uv run tangle sdk secrets delete NAME [--force] +tangle sdk secrets list +tangle sdk secrets create NAME --from-env ENVVAR [-d DESCRIPTION] [--expires-at WHEN] +tangle sdk secrets update NAME --from-env ENVVAR +tangle sdk secrets delete NAME [--force] ``` Prefer `--from-env`/`-e ENVVAR` over `--value`/`-v` to avoid leaking the value @@ -344,7 +342,7 @@ authenticating identity — see [`references/secrets.md`](secrets.md). ## Cancelling a Run ```bash -uv run tangle sdk pipeline-runs cancel RUN_ID +tangle sdk pipeline-runs cancel RUN_ID ``` ## Programmatic client (Python) @@ -396,12 +394,12 @@ environment default. There is no `auth` command group. | — | `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics. | Run links: there is no hosted dashboard URL to assume — use `/runs/` -or inspect via `uv run tangle sdk pipeline-runs details RUN_ID`. +or inspect via `tangle sdk pipeline-runs details RUN_ID`. Example for a protected backend: ```bash -uv run tangle sdk pipeline-runs submit pipeline.yaml \ +tangle sdk pipeline-runs submit pipeline.yaml \ --base-url https://api.example \ --auth-header 'Bearer …' \ -H 'X-Gateway-Auth: …' \ diff --git a/skills/tangent/references/uploading-artifacts.md b/skills/tangent/references/uploading-artifacts.md index d4896f1..0c1b2c7 100644 --- a/skills/tangent/references/uploading-artifacts.md +++ b/skills/tangent/references/uploading-artifacts.md @@ -18,8 +18,8 @@ it into a pipeline — that hides the dependency from the graph. > **Backend-dependent.** The OSS `tangle` core does **not** ship a built-in ingest > component. Whether one is available — and what its name, inputs, and outputs are > — depends on what components your backend has published. Discover what's -> available with `uv run tangle sdk published-components library` and -> `uv run tangle sdk published-components search ` (search is **optional** and +> available with `tangle sdk published-components library` and +> `tangle sdk published-components search ` (search is **optional** and > may return empty on a fresh OSS install — see > [`OSS-CONVENTIONS.md` §10 D11](../OSS-CONVENTIONS.md)). If your backend provides > no ingest component at all, fall back to a Python component that reads the URI @@ -38,9 +38,9 @@ A typical ingest component looks like this: Inspect whatever your backend provides before wiring it in: ```bash -uv run tangle sdk published-components inspect --name "" --full-spec +tangle sdk published-components inspect --name "" --full-spec # or, if you already have the digest: -uv run tangle sdk published-components inspect --digest --full-spec +tangle sdk published-components inspect --digest --full-spec ``` ## Recipe A — Add a new ingest task to an existing pipeline @@ -51,7 +51,7 @@ external evaluation set. 1. **Export the pipeline's root spec** so you can edit it locally: ```bash - uv run tangle sdk pipeline-runs export --output /tmp/pipeline.yaml + tangle sdk pipeline-runs export --output /tmp/pipeline.yaml ``` `export` writes the root spec as-is (there is no `--dehydrate`; omit `--output` to print to stdout). To iterate on the run, hand-edit this file and `submit` it — @@ -87,10 +87,10 @@ external evaluation set. 4. **Validate, then submit** (hydrate is the default; `submit` never waits — block afterward with `pipeline-runs wait` if you need to): ```bash - uv run tangle sdk pipelines validate /tmp/pipeline.yaml - uv run tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ + tangle sdk pipelines validate /tmp/pipeline.yaml + tangle sdk pipeline-runs submit /tmp/pipeline.yaml \ --arg input_artifact_uri="://path/to/artifact/" - uv run tangle sdk pipeline-runs wait --max-wait 600 + tangle sdk pipeline-runs wait --max-wait 600 ``` (Pass `--arg` only if you wired the URI as a `graphInput` in step 2; for a @@ -141,10 +141,10 @@ staged variants. 3. Supply the URI per run with `--arg` (or `--args-json`). **Run arguments bind to the pipeline's top-level `inputs:`** — they are distinct from task-level `arguments:` wiring inside the pipeline YAML (as shown in step 2 above). See - `uv run tangle sdk pipeline-runs submit --help` for the full surface, and + `tangle sdk pipeline-runs submit --help` for the full surface, and [`references/tangle-tools.md`](tangle-tools.md) for the canonical submit form. ```bash - uv run tangle sdk pipeline-runs submit pipeline.yaml \ + tangle sdk pipeline-runs submit pipeline.yaml \ --arg input_artifact_uri="://path/to/artifact/" ``` @@ -215,7 +215,7 @@ hard-coded into the YAML. - **No ingest component? Read the URI in a Python component.** If your backend registers no ingest component, generate a small one from a Python source file that fetches the URI and writes a `Data` output: - `uv run tangle sdk components generate from-python fetch.py --image ` + `tangle sdk components generate from-python fetch.py --image ` (build/push the image yourself; `set-container-image` is a stub — do not use it). See [`agents/builder.md`](../agents/builder.md). - **Don't hand-roll throwaway ingest.** If you find yourself shelling out to copy diff --git a/tests/test_packaging.py b/tests/test_packaging.py index f083f6a..41106c4 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -144,8 +144,11 @@ def test_tangent_skill_bundle_is_in_repo_and_current() -> None: assert "not yet a public PyPI package" not in markdown assert "pip install 'tangle-cli[native]'" not in markdown assert "uv install" not in markdown + assert "Run commands as `tangle …` from a checkout" not in markdown + assert "Published-package usage is the default" in markdown assert "uv tool install tangle-cli" in markdown assert "uvx --from tangle-cli tangle" in markdown + assert "installed-tool form" in markdown assert "uv pip install tangle-cli` only inside an explicitly managed virtualenv" in markdown assert "pip install tangle-cli" in markdown assert "compatibility/no-op" in markdown From fd945bdf1090818f7943a21088f253a1df28337b Mon Sep 17 00:00:00 2001 From: Volv G Date: Mon, 6 Jul 2026 08:23:43 -0700 Subject: [PATCH 105/111] chore: prepare 0.1.0 stable release Assisted-By: devx/9c4ad24f-24d3-4eb9-8747-dbf53e3221df --- README.md | 4 ++-- packages/tangle-api/pyproject.toml | 2 +- pyproject.toml | 4 ++-- tests/test_packaging.py | 8 ++++---- uv.lock | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5482ae1..ff6e5a5 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ uv run tangle api --help uv run tangle sdk pipelines validate pipeline.yaml ``` -Custom API/codegen users can still run codegen from the fully capable install; generating bindings does not require removing the official `tangle-api` package. For project-local generated APIs, generate into a local source tree such as `src/tangle_api/generated` (and `src/tangle_api/schema/openapi.json` when you want `tangle api --schema-source official`) and run from that project so local `src/tangle_api` shadows site-packages. For packaged custom APIs, publish/provide a distribution named `tangle-api` with a version compatible with this `tangle-cli` release (for example `0.0.1a3+yourorg` for a `tangle-cli` dependency on `tangle-api==0.0.1a3`) via a private index, `--find-links`, or uv sources. As an expert escape hatch, `--no-deps` installs only `tangle-cli` and skips all dependencies, so that environment must manually provide every required runtime dependency plus its generated/custom `tangle_api`; this is acceptable for controlled codegen/custom scenarios but not normal UX. +Custom API/codegen users can still run codegen from the fully capable install; generating bindings does not require removing the official `tangle-api` package. For project-local generated APIs, generate into a local source tree such as `src/tangle_api/generated` (and `src/tangle_api/schema/openapi.json` when you want `tangle api --schema-source official`) and run from that project so local `src/tangle_api` shadows site-packages. For packaged custom APIs, publish/provide a distribution named `tangle-api` with a version compatible with this `tangle-cli` release (for example `0.1.0+yourorg` for a `tangle-cli` dependency on `tangle-api==0.1.0`) via a private index, `--find-links`, or uv sources. As an expert escape hatch, `--no-deps` installs only `tangle-cli` and skips all dependencies, so that environment must manually provide every required runtime dependency plus its generated/custom `tangle_api`; this is acceptable for controlled codegen/custom scenarios but not normal UX. ## Agent skills @@ -350,7 +350,7 @@ uv run python -m tangle_cli.openapi.codegen \ --out src/tangle_api/generated ``` -That project-local `tangle_api` package can be an editable/package source tree. If you ship the custom API bindings as a wheel or source distribution, use the distribution name `tangle-api` and a compatible version for the `tangle-cli` release you are using. A PEP 440 local version such as `0.0.1a3+yourorg` can satisfy a public `==0.0.1a3` dependency while distinguishing your private build. Provide that package through your private index, `--find-links`, or uv source configuration so the resolver chooses it instead of the public official package. +That project-local `tangle_api` package can be an editable/package source tree. If you ship the custom API bindings as a wheel or source distribution, use the distribution name `tangle-api` and a compatible version for the `tangle-cli` release you are using. A PEP 440 local version such as `0.1.0+yourorg` can satisfy a public `==0.1.0` dependency while distinguishing your private build. Provide that package through your private index, `--find-links`, or uv source configuration so the resolver chooses it instead of the public official package. Generate from a backend checkout explicitly: diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index ff045ad..29d54c6 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-api" -version = "0.0.1a3" +version = "0.1.0" description = "Checked-in generated Tangle API models and operation proxies" readme = "README.md" authors = [ diff --git a/pyproject.toml b/pyproject.toml index 239b68a..ce556a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.0.1a3" +version = "0.1.0" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ @@ -18,7 +18,7 @@ dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", "requests>=2.32.0", - "tangle-api==0.0.1a3", + "tangle-api==0.1.0", "tomli>=2.0; python_version < '3.11'", ] diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 41106c4..9e0e4d2 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -179,8 +179,8 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Version: 0.0.1a3" in metadata - assert "Requires-Dist: tangle-api==0.0.1a3" in requires_dist + assert "Version: 0.1.0" in metadata + assert "Requires-Dist: tangle-api==0.1.0" in requires_dist assert not any("extra == 'native'" in line for line in requires_dist) assert "Provides-Extra: native" in metadata assert "tangle = tangle_cli.cli:main" in entry_points @@ -208,7 +208,7 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api def test_custom_tangle_api_local_version_can_satisfy_cli_pin() -> None: - assert Version("0.0.1a3+yourorg") in SpecifierSet("==0.0.1a3") + assert Version("0.1.0+yourorg") in SpecifierSet("==0.1.0") def test_tangle_cli_wheel_api_refresh_builds_in_expert_no_deps_fallback(tmp_path) -> None: @@ -397,7 +397,7 @@ def test_default_wheels_provide_static_client_binding(tmp_path) -> None: metadata = archive.read(metadata_name).decode() requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] - assert "Version: 0.0.1a3" in metadata + assert "Version: 0.1.0" in metadata assert "Requires-Dist: pydantic>=2.0" in requires_dist assert not any("tangle-cli" in line for line in requires_dist) env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} diff --git a/uv.lock b/uv.lock index 635da2b..ca1d97b 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-26T13:32:13.031764Z" +exclude-newer = "2026-06-29T15:21:31.720493Z" exclude-newer-span = "P7D" [manifest] @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "tangle-api" -version = "0.0.1a3" +version = "0.1.0" source = { editable = "packages/tangle-api" } dependencies = [ { name = "pydantic" }, @@ -1786,7 +1786,7 @@ requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [[package]] name = "tangle-cli" -version = "0.0.1a3" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, From 475975fb0c8f28f2d736cf65931300cdfa82fac1 Mon Sep 17 00:00:00 2001 From: Volv G Date: Mon, 6 Jul 2026 16:04:06 -0700 Subject: [PATCH 106/111] fix(pipeline-run): back off failed-submit recovery lookups Assisted-By: devx/8480f2ea-e082-4ce7-83b5-2b75a9e3161c --- .../src/tangle_cli/pipeline_run_manager.py | 17 ++-- tests/test_pipeline_runs_cli.py | 96 ++++++++++++++++--- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 4048882..56b47ed 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -37,8 +37,7 @@ _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 +_SUBMIT_RECOVERY_BACKOFF_SECONDS = (0.5, 1.0, 2.0) class PipelineRunError(RuntimeError): @@ -1557,11 +1556,19 @@ def _recover_submitted_run_after_submit_error( ) -> dict[str, Any] | None: if not submission_id: return None - for lookup_attempt in range(1, _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS + 1): + total_lookup_attempts = len(_SUBMIT_RECOVERY_BACKOFF_SECONDS) + for lookup_attempt, delay_seconds in enumerate(_SUBMIT_RECOVERY_BACKOFF_SECONDS, start=1): + self.logger.info( + "Waiting " + f"{delay_seconds:g}s before checking whether failed submit already created a pipeline run " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, " + f"lookup_attempt={lookup_attempt}/{total_lookup_attempts})" + ) + time.sleep(delay_seconds) 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})" + f"lookup_attempt={lookup_attempt}/{total_lookup_attempts})" ) try: matches = self._submitted_runs_for_submission_id(submission_id) @@ -1598,8 +1605,6 @@ def _recover_submitted_run_after_submit_error( 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}); " diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 26b6530..7148023 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -11,15 +11,16 @@ 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 ( + AmbiguousPipelineRunRecoveryError, PipelineRunContext, + PipelineRunError, PipelineRunHooks, PipelineRunManager, - PipelineRunError, PipelineWaitOutcome, PipelineWaitPoll, ) +from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks def run_app(app, args: list[str]) -> None: @@ -199,7 +200,10 @@ def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_ "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", + "text": ( + "name: Text Component\n_source_dir: /tmp/private\nimplementation:\n" + " container:\n image: busybox\n" + ), } } } @@ -970,7 +974,9 @@ def fake_regenerate_yaml(self, **kwargs): 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"] + submitted_task = fake_client.created[0]["root_task"]["componentRef"]["spec"]["implementation"]["graph"][ + "tasks" + ]["generated"] assert submitted_task["componentRef"]["name"] == "Submit Generated Component" @@ -1670,9 +1676,9 @@ def after_poll(self, poll, context): ] - 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) + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) pipeline_path = tmp_path / "pipeline.yaml" class DynamicTimeHooks(PipelineRunnerHooks): @@ -1727,6 +1733,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: assert hooks.prepare_run_arguments_calls == 1 assert hooks.submit_errors == [] assert len(client.created) == 1 + assert sleeps == [0.5] submission_id = client.created[0]["annotations"]["tangle-cli/submission-id"] assert submission_id assert len(client.list_calls) == 1 @@ -1734,11 +1741,74 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: assert submission_id in client.list_calls[0]["filter_query"] +def test_pipeline_runs_submit_failure_recovery_waits_for_delayed_registration(monkeypatch) -> None: + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) + + class DelayedRecoveryClient(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"}], + "next_page_token": None, + } + + client = DelayedRecoveryClient() + manager = PipelineRunManager(client=client) + body = {"root_task": {"componentRef": {"spec": {"name": "delayed-recovery"}}}} + + result = manager.run_prepared_body(body) + + assert result["response"]["id"] == "run-created" + assert result["context"].metadata["recovered_after_submit_error"] is True + assert len(client.created) == 1 + assert len(client.list_calls) == 3 + assert sleeps == [0.5, 1.0, 2.0] + + +def test_pipeline_runs_submit_failure_recovery_refuses_ambiguous_matches(monkeypatch) -> None: + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) + + class AmbiguousRecoveryClient(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-1", "root_execution_id": "exec-created-1"}, + {"id": "run-created-2", "root_execution_id": "exec-created-2"}, + ], + "next_page_token": None, + } + + client = AmbiguousRecoveryClient() + manager = PipelineRunManager(client=client) + body = {"root_task": {"componentRef": {"spec": {"name": "ambiguous-recovery"}}}} + + with pytest.raises(AmbiguousPipelineRunRecoveryError): + manager.run_prepared_body(body, max_attempts=2) + + assert len(client.created) == 1 + assert len(client.list_calls) == 1 + assert sleeps == [0.5] + + 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) + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) pipeline_path = tmp_path / "pipeline.yaml" class DynamicTimeHooks(PipelineRunnerHooks): @@ -1799,12 +1869,14 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: assert client.created[0]["annotations"]["tangle-cli/submission-id"] == client.created[1]["annotations"][ "tangle-cli/submission-id" ] - assert len(client.list_calls) == 4 - + assert len(client.list_calls) == 2 * len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + expected_sleeps = list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + assert sleeps == expected_sleeps + expected_sleeps 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) + sleeps: list[float] = [] + monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value)) pipeline_path = tmp_path / "pipeline.yaml" events: list[tuple[str, int, str | None]] = [] @@ -1842,7 +1914,7 @@ def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: self.list_calls.append(kwargs) - if len(self.list_calls) <= 2: + if len(self.list_calls) <= len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS): return {"pipeline_runs": [], "next_page_token": None} return { "pipeline_runs": [ @@ -1862,6 +1934,8 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: assert result["context"].metadata["recovered_after_submit_error"] is True assert hooks.prepare_run_arguments_calls == 1 assert len(client.created) == 1 + assert len(client.list_calls) == len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + 1 + assert sleeps == [0.5, 1.0, 2.0, 0.5] assert events == [("before_retry", 2, None), ("after_retry_submit", 2, "run-created")] From e5cf7d60e27c890ca6e4fbf0e2db69adf68d906c Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 7 Jul 2026 19:35:33 -0700 Subject: [PATCH 107/111] feat(pipeline-run): configure submit recovery attempts Assisted-By: devx/12bfa114-4ee7-4929-a37c-98018f6b0f41 --- .../tangle-cli/src/tangle_cli/__init__.py | 2 +- .../src/tangle_cli/pipeline_run_manager.py | 31 +++++++- .../src/tangle_cli/pipeline_runner.py | 2 + .../src/tangle_cli/pipeline_runs_cli.py | 18 ++++- pyproject.toml | 2 +- tests/test_packaging.py | 3 +- tests/test_pipeline_runs_cli.py | 72 ++++++++++++++++--- uv.lock | 4 +- 8 files changed, 114 insertions(+), 20 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py index a1cb5f7..dcaea45 100644 --- a/packages/tangle-cli/src/tangle_cli/__init__.py +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -14,6 +14,6 @@ try: __version__ = metadata_version("tangle-cli") except PackageNotFoundError: - __version__ = "0.1.0" + __version__ = "0.1.1" __all__ = ["TangleDynamicDiscoveryClient", "__version__"] diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 56b47ed..1736d76 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -37,7 +37,8 @@ _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_BACKOFF_SECONDS = (0.5, 1.0, 2.0) +_SUBMIT_RECOVERY_BACKOFF_SECONDS = (0.5, 1.0, 2.0, 4.0, 8.0, 16.0) +_DEFAULT_SUBMIT_RECOVERY_ATTEMPTS = 2 class PipelineRunError(RuntimeError): @@ -1528,6 +1529,11 @@ def _submission_id_from_body(body: Mapping[str, Any]) -> str | None: submission_id = annotations.get(_SUBMISSION_ID_ANNOTATION_KEY) return str(submission_id) if submission_id else None + @staticmethod + def _submit_recovery_backoff_seconds(submit_recovery_attempts: int) -> tuple[float, ...]: + attempt_count = max(0, min(int(submit_recovery_attempts), len(_SUBMIT_RECOVERY_BACKOFF_SECONDS))) + return _SUBMIT_RECOVERY_BACKOFF_SECONDS[:attempt_count] + def _submitted_runs_for_submission_id(self, submission_id: str) -> list[dict[str, Any]]: query = { "and": [ @@ -1553,11 +1559,21 @@ def _recover_submitted_run_after_submit_error( self, *, submission_id: str | None, + submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, ) -> dict[str, Any] | None: if not submission_id: return None - total_lookup_attempts = len(_SUBMIT_RECOVERY_BACKOFF_SECONDS) - for lookup_attempt, delay_seconds in enumerate(_SUBMIT_RECOVERY_BACKOFF_SECONDS, start=1): + backoff_seconds = self._submit_recovery_backoff_seconds(submit_recovery_attempts) + total_lookup_attempts = len(backoff_seconds) + if total_lookup_attempts == 0: + self.logger.warn( + "Submit recovery lookup disabled " + f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, " + f"submit_recovery_attempts={submit_recovery_attempts}); " + "resubmitting the same frozen body with preserved inputs." + ) + return None + for lookup_attempt, delay_seconds in enumerate(backoff_seconds, start=1): self.logger.info( "Waiting " f"{delay_seconds:g}s before checking whether failed submit already created a pipeline run " @@ -1652,6 +1668,7 @@ def _run_body_factory( timeout_clock: str = "monotonic", exit_on_first_failure: bool = False, metadata: dict[str, Any] | None = None, + submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, metadata_factory: Callable[ [int, PipelineRunContext | None, Exception | None], dict[str, Any] ] | None = None, @@ -1728,6 +1745,7 @@ def _run_body_factory( if reused_after_submit_failure: recovered_response = self._recover_submitted_run_after_submit_error( submission_id=self._submission_id_from_body(body), + submit_recovery_attempts=submit_recovery_attempts, ) if recovered_response is not None: response = self._adopt_submitted_run( @@ -1759,6 +1777,7 @@ def _run_body_factory( ) recovered_response = self._recover_submitted_run_after_submit_error( submission_id=submission_id_for_recovery, + submit_recovery_attempts=submit_recovery_attempts, ) if recovered_response is None: self.hooks.on_submit_error(submit_exc, context=context) @@ -1839,6 +1858,7 @@ def run_prepared_body( timeout_clock: str = "monotonic", exit_on_first_failure: bool = False, metadata: dict[str, Any] | None = None, + submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, ) -> dict[str, Any]: """Submit/wait/retry an already prepared submit body. @@ -1867,6 +1887,7 @@ def body_factory( timeout_clock=timeout_clock, exit_on_first_failure=exit_on_first_failure, metadata=metadata, + submit_recovery_attempts=submit_recovery_attempts, ) def run_pipeline_spec( @@ -1887,6 +1908,7 @@ def run_pipeline_spec( timeout_clock: str = "monotonic", exit_on_first_failure: bool = False, metadata: dict[str, Any] | None = None, + submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, ) -> dict[str, Any]: """Submit/wait/retry an already hydrated/validated in-memory spec.""" @@ -1916,6 +1938,7 @@ def body_factory( timeout_clock=timeout_clock, exit_on_first_failure=exit_on_first_failure, metadata=metadata, + submit_recovery_attempts=submit_recovery_attempts, ) def run_pipeline( @@ -1936,6 +1959,7 @@ def run_pipeline( timeout_clock: str = "monotonic", exit_on_first_failure: bool = False, metadata: dict[str, Any] | None = None, + submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, ) -> dict[str, Any]: """Submit (and optionally wait for) a pipeline with lifecycle hooks. @@ -1970,6 +1994,7 @@ def body_factory( timeout_clock=timeout_clock, exit_on_first_failure=exit_on_first_failure, metadata=metadata, + submit_recovery_attempts=submit_recovery_attempts, ) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 2b50d39..d0580f8 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -507,6 +507,7 @@ def run_pipeline( open_browser: bool = False, include_next_steps: bool = False, metadata: dict[str, Any] | None = None, + submit_recovery_attempts: int = 2, ) -> dict[str, Any]: """Run a pipeline path through generic preparation + lifecycle hooks. @@ -603,6 +604,7 @@ def metadata_factory( timeout_clock=timeout_clock, exit_on_first_failure=exit_on_first_failure, metadata_factory=metadata_factory, + submit_recovery_attempts=submit_recovery_attempts, ) context = result.get("context") attempt = context.attempt if isinstance(context, PipelineRunContext) else max(preparations) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py index 8c67c84..2da0eb5 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py @@ -28,6 +28,7 @@ from .logger import Logger, logger_for_log_type from .pipeline_run_annotations import AnnotationManager from .pipeline_run_manager import ( + _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, PipelineRunError, PipelineRunHooks, PipelineRunManager, @@ -166,6 +167,15 @@ def pipeline_runs_submit( auth_header: AuthHeaderOption = None, header: HeaderOption = None, config: ConfigOption = None, + submit_recovery_attempts: Annotated[ + int, + Parameter( + help=( + "Number of post-failed-submit recovery lookups before resubmitting; " + "higher values wait longer for delayed run registration." + ) + ), + ] = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS, log_type: LogTypeOption = "console", ) -> None: """Hydrate and submit a local pipeline YAML file as a run.""" @@ -181,6 +191,7 @@ def pipeline_runs_submit( "run_as": (run_as, None), "trusted_source": (trusted_source, None), "trusted_hydration_cli": ("trusted_hydration_cli", trusted_hydration, None, False), + "submit_recovery_attempts": (submit_recovery_attempts, _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS), "log_type": (log_type, "console"), **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header), } @@ -194,7 +205,12 @@ def action(manager: PipelineRunManager, args: ArgsContainer) -> dict[str, Any]: } if args.dry_run: return manager.build_submit_body(args.pipeline_path, **kwargs) - return manager.submit_pipeline(args.pipeline_path, **kwargs) + result = manager.run_pipeline( + args.pipeline_path, + **kwargs, + submit_recovery_attempts=args.submit_recovery_attempts, + ) + return result["response"] _run_manager_action(config, base_url, specs, action) diff --git a/pyproject.toml b/pyproject.toml index ce556a6..b04e037 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.1.0" +version = "0.1.1" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 9e0e4d2..6aa2593 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -14,7 +14,6 @@ from tangle_cli.openapi import codegen - _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -179,7 +178,7 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Version: 0.1.0" in metadata + assert "Version: 0.1.1" in metadata assert "Requires-Dist: tangle-api==0.1.0" in requires_dist assert not any("extra == 'native'" in line for line in requires_dist) assert "Provides-Extra: native" in metadata diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 7148023..f20249f 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -135,7 +135,9 @@ def test_pipeline_runs_help_exposes_run_commands_not_local_pipeline_commands(cap assert "diagram" not in output run_app(app, ["sdk", "pipeline-runs", "submit", "--help"]) - assert "--log-type" in capsys.readouterr().out + submit_help = capsys.readouterr().out + assert "--log-type" in submit_help + assert "--submit-recovery-attempts" in submit_help def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path, capsys): @@ -161,12 +163,62 @@ def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path, result = json.loads(capsys.readouterr().out) assert result == {"id": "run-1", "root_execution_id": "exec-1"} - assert fake_client.created[0]["annotations"] == {"team": "oss"} + assert fake_client.created[0]["annotations"]["team"] == "oss" + assert fake_client.created[0]["annotations"]["tangle-cli/submission-id"] 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_uses_default_submit_recovery_attempts(monkeypatch, tmp_path: Path, capsys): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + captured: dict[str, Any] = {} + + def fake_run_pipeline(self, pipeline_path, **kwargs): + del self, pipeline_path + captured.update(kwargs) + return {"response": {"id": "run-1"}} + + monkeypatch.setattr(PipelineRunManager, "run_pipeline", fake_run_pipeline) + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: FakeClient()) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--no-hydrate"]) + + assert json.loads(capsys.readouterr().out) == {"id": "run-1"} + assert captured["submit_recovery_attempts"] == pipeline_run_manager._DEFAULT_SUBMIT_RECOVERY_ATTEMPTS + + +def test_pipeline_runs_submit_accepts_submit_recovery_attempts(monkeypatch, tmp_path: Path, capsys): + pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml") + captured: dict[str, Any] = {} + + def fake_run_pipeline(self, pipeline_path, **kwargs): + del self, pipeline_path + captured.update(kwargs) + return {"response": {"id": "run-1"}} + + monkeypatch.setattr(PipelineRunManager, "run_pipeline", fake_run_pipeline) + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: FakeClient()) + app = cli.build_app() + + run_app( + app, + [ + "sdk", + "pipeline-runs", + "submit", + str(pipeline_path), + "--no-hydrate", + "--submit-recovery-attempts", + "6", + ], + ) + + assert json.loads(capsys.readouterr().out) == {"id": "run-1"} + assert captured["submit_recovery_attempts"] == 6 + + 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" @@ -1752,7 +1804,7 @@ def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]: def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: self.list_calls.append(kwargs) - if len(self.list_calls) <= 2: + if len(self.list_calls) < len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS): return {"pipeline_runs": [], "next_page_token": None} return { "pipeline_runs": [{"id": "run-created", "root_execution_id": "exec-created"}], @@ -1763,13 +1815,13 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: manager = PipelineRunManager(client=client) body = {"root_task": {"componentRef": {"spec": {"name": "delayed-recovery"}}}} - result = manager.run_prepared_body(body) + result = manager.run_prepared_body(body, submit_recovery_attempts=6) assert result["response"]["id"] == "run-created" assert result["context"].metadata["recovered_after_submit_error"] is True assert len(client.created) == 1 - assert len(client.list_calls) == 3 - assert sleeps == [0.5, 1.0, 2.0] + assert len(client.list_calls) == len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + assert sleeps == list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) def test_pipeline_runs_submit_failure_recovery_refuses_ambiguous_matches(monkeypatch) -> None: @@ -1869,8 +1921,8 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: assert client.created[0]["annotations"]["tangle-cli/submission-id"] == client.created[1]["annotations"][ "tangle-cli/submission-id" ] - assert len(client.list_calls) == 2 * len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) - expected_sleeps = list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + expected_sleeps = list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS[:2]) + assert len(client.list_calls) == 2 * len(expected_sleeps) assert sleeps == expected_sleeps + expected_sleeps @@ -1927,7 +1979,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: client = RecoverOnRetryClient() manager = PipelineRunner(client=client, hooks=hooks) - result = manager.run_pipeline(pipeline_path, hydrate=False, max_attempts=2) + result = manager.run_pipeline(pipeline_path, hydrate=False, max_attempts=2, submit_recovery_attempts=6) assert result["response"]["id"] == "run-created" assert result["context"].attempt == 2 @@ -1935,7 +1987,7 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]: assert hooks.prepare_run_arguments_calls == 1 assert len(client.created) == 1 assert len(client.list_calls) == len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + 1 - assert sleeps == [0.5, 1.0, 2.0, 0.5] + assert sleeps == [*pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS, 0.5] assert events == [("before_retry", 2, None), ("after_retry_submit", 2, "run-created")] diff --git a/uv.lock b/uv.lock index ca1d97b..e622122 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-29T15:21:31.720493Z" +exclude-newer = "2026-07-01T02:35:01.441363Z" exclude-newer-span = "P7D" [manifest] @@ -1786,7 +1786,7 @@ requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [[package]] name = "tangle-cli" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" }, From b098c5907eaf97267cc2ccc8d73e3c90e035d52b Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 7 Jul 2026 20:21:50 -0700 Subject: [PATCH 108/111] ci(release): allow independent tangle-api version --- .github/workflows/release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a7edf13..46216d5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -41,16 +41,16 @@ jobs: uv run --isolated --no-project \ --with dist/tangle_cli-*.whl \ --with dist/tangle_api-*.whl \ - python -c "import importlib.metadata as m; import tangle_api.generated.models; import tangle_api.schema; assert m.version('tangle-api') == m.version('tangle-cli')" + python -c "import importlib.metadata as m; import tangle_cli; import tangle_api.generated.models; import tangle_api.schema; print(f'tangle-cli {m.version(\"tangle-cli\")} with tangle-api {m.version(\"tangle-api\")}')" - name: Smoke test tangle-api source distribution run: | uv run --isolated --no-project \ --with dist/tangle_cli-*.tar.gz \ --with dist/tangle_api-*.tar.gz \ - python -c "import importlib.metadata as m; import tangle_api.generated.models; import tangle_api.schema; assert m.version('tangle-api') == m.version('tangle-cli')" + python -c "import importlib.metadata as m; import tangle_cli; import tangle_api.generated.models; import tangle_api.schema; print(f'tangle-cli {m.version(\"tangle-cli\")} with tangle-api {m.version(\"tangle-api\")}')" - name: Smoke test native extra compatibility alias from local dist run: | cli_wheel="$(echo dist/tangle_cli-*.whl)" uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version - name: Publish - run: uv publish + run: uv publish --check-url https://pypi.org/simple/ From 883aad9e0e605835f5303634455e416a9f18b47a Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 7 Jul 2026 20:27:23 -0700 Subject: [PATCH 109/111] ci(release): publish only tangle-cli artifacts --- .github/workflows/release.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 46216d5..0aac43f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -52,5 +52,5 @@ jobs: run: | cli_wheel="$(echo dist/tangle_cli-*.whl)" uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version - - name: Publish - run: uv publish --check-url https://pypi.org/simple/ + - name: Publish tangle-cli + run: uv publish --check-url https://pypi.org/simple/ dist/tangle_cli-* From 2bc8cc86329214c85efafc551ebf9360bc7a8e1a Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 7 Jul 2026 21:51:33 -0700 Subject: [PATCH 110/111] ci(release): publish packages by version existence --- .github/workflows/release.yaml | 45 +++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0aac43f..65e61db 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -52,5 +52,48 @@ jobs: run: | cli_wheel="$(echo dist/tangle_cli-*.whl)" uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version + - name: Determine packages to publish + id: publish-plan + run: | + python - <<'PY' + import json + import os + import urllib.error + import urllib.request + from pathlib import Path + import tomllib + + packages = [ + ("tangle-cli", "pyproject.toml", "dist/tangle_cli-*", "publish_cli"), + ("tangle-api", "packages/tangle-api/pyproject.toml", "dist/tangle_api-*", "publish_api"), + ] + + def version_exists(package: str, version: str) -> bool: + url = f"https://pypi.org/pypi/{package}/json" + try: + with urllib.request.urlopen(url, timeout=20) as response: + data = json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return False + raise + return version in data.get("releases", {}) + + github_output = Path(os.environ["GITHUB_OUTPUT"]) + with github_output.open("a") as output: + for package, pyproject_path, dist_glob, output_name in packages: + version = tomllib.loads(Path(pyproject_path).read_text())["project"]["version"] + exists = version_exists(package, version) + should_publish = not exists + print( + f"{package} {version}: " + f"{'publish ' + dist_glob if should_publish else 'skip; version already exists on PyPI'}" + ) + print(f"{output_name}={'true' if should_publish else 'false'}", file=output) + PY - name: Publish tangle-cli - run: uv publish --check-url https://pypi.org/simple/ dist/tangle_cli-* + if: steps.publish-plan.outputs.publish_cli == 'true' + run: uv publish dist/tangle_cli-* + - name: Publish tangle-api + if: steps.publish-plan.outputs.publish_api == 'true' + run: uv publish dist/tangle_api-* From 07127b93f0058837ddaae002b599f517662ae0eb Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 7 Jul 2026 22:12:02 -0700 Subject: [PATCH 111/111] ci(release): use package-scoped publish tags --- .github/workflows/release.yaml | 64 +++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 65e61db..6e56a26 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,8 +3,9 @@ name: "Publish release to PyPI" on: push: tags: - # Publish on any tag starting with a `v`, e.g., v0.0.1a2 - - v* + # Publish package-scoped releases, e.g., tangle-cli-v0.1.1 or tangle-api-v0.1.0. + - "tangle-cli-v*" + - "tangle-api-v*" jobs: run: @@ -52,21 +53,39 @@ jobs: run: | cli_wheel="$(echo dist/tangle_cli-*.whl)" uv run --isolated --no-project --find-links dist --with "${cli_wheel}[native]" tangle-cli version - - name: Determine packages to publish + - name: Determine package to publish id: publish-plan run: | python - <<'PY' import json import os + import sys import urllib.error import urllib.request from pathlib import Path import tomllib - packages = [ - ("tangle-cli", "pyproject.toml", "dist/tangle_cli-*", "publish_cli"), - ("tangle-api", "packages/tangle-api/pyproject.toml", "dist/tangle_api-*", "publish_api"), - ] + tag = os.environ["GITHUB_REF_NAME"] + release_packages = { + "tangle-cli-v": ("tangle-cli", "pyproject.toml", "dist/tangle_cli-*"), + "tangle-api-v": ("tangle-api", "packages/tangle-api/pyproject.toml", "dist/tangle_api-*"), + } + + for tag_prefix, package_config in release_packages.items(): + if tag.startswith(tag_prefix): + package, pyproject_path, dist_glob = package_config + tag_version = tag.removeprefix(tag_prefix) + break + else: + supported_tags = ", ".join(f"{prefix}*" for prefix in release_packages) + sys.exit(f"Unsupported release tag {tag!r}. Expected one of: {supported_tags}.") + + package_version = tomllib.loads(Path(pyproject_path).read_text())["project"]["version"] + if tag_version != package_version: + sys.exit( + f"Release tag {tag!r} declares {package} version {tag_version}, " + f"but {pyproject_path} declares {package_version}." + ) def version_exists(package: str, version: str) -> bool: url = f"https://pypi.org/pypi/{package}/json" @@ -79,21 +98,18 @@ jobs: raise return version in data.get("releases", {}) - github_output = Path(os.environ["GITHUB_OUTPUT"]) - with github_output.open("a") as output: - for package, pyproject_path, dist_glob, output_name in packages: - version = tomllib.loads(Path(pyproject_path).read_text())["project"]["version"] - exists = version_exists(package, version) - should_publish = not exists - print( - f"{package} {version}: " - f"{'publish ' + dist_glob if should_publish else 'skip; version already exists on PyPI'}" - ) - print(f"{output_name}={'true' if should_publish else 'false'}", file=output) + should_publish = not version_exists(package, package_version) + print( + f"{tag}: " + f"{'publish ' + dist_glob if should_publish else package + ' ' + package_version + ' already exists on PyPI; skipping'}" + ) + + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + print(f"package={package}", file=output) + print(f"version={package_version}", file=output) + print(f"dist_glob={dist_glob}", file=output) + print(f"should_publish={'true' if should_publish else 'false'}", file=output) PY - - name: Publish tangle-cli - if: steps.publish-plan.outputs.publish_cli == 'true' - run: uv publish dist/tangle_cli-* - - name: Publish tangle-api - if: steps.publish-plan.outputs.publish_api == 'true' - run: uv publish dist/tangle_api-* + - name: Publish selected package + if: steps.publish-plan.outputs.should_publish == 'true' + run: uv publish ${{ steps.publish-plan.outputs.dist_glob }}