diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ef37213 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## What does this PR change? + + + +## Why? + + + +## Testing + + + +## Checklist + +- [ ] I have read `CONTRIBUTING.md`. +- [ ] Every commit in this PR includes a valid DCO `Signed-off-by` line. +- [ ] I have the right to submit all material in this PR. +- [ ] I have not included secrets or confidential data. +- [ ] I have updated tests where appropriate. +- [ ] I have updated documentation where appropriate. +- [ ] I have preserved required third-party licenses and attributions. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0514835 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +# The default-branch ruleset of Kaiten's SDK repositories requires three checks, named `lint`, +# `build` and `test`. The jobs below report exactly those names; the per-version test jobs +# report under their own names, and `test` answers for all of them. + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - run: uv sync --locked + + # The models come from openapi/ and the sync client from the async one. A commit that + # edits either source without regenerating would ship code the repository cannot + # reproduce, so the check regenerates and compares. + - name: Generated code is up to date + run: | + uv run python scripts/generate_models.py --check + uv run python scripts/unasync.py --check + + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + + tests: + name: test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v5 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - run: uv sync --locked + - run: uv run pytest --cov --cov-report=term-missing + + lowest-dependencies: + name: test (lowest dependencies) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.10" + + # The lower bounds in pyproject.toml are a promise to every consumer; this is what keeps + # them honest. + - run: uv sync --resolution lowest-direct + - run: uv run pytest + + # The one `test` check the ruleset requires, standing for every test job above. It runs even + # when one of them failed, and passes only when all of them succeeded: a required check that + # is skipped counts as passing, so a plain `needs` would let a failure through. + test: + name: test + if: always() + needs: [tests, lowest-dependencies] + runs-on: ubuntu-latest + steps: + - name: Every test job passed + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "test jobs: $RESULTS" + jq -e 'length > 0 and all(.[]; . == "success")' <<< "$RESULTS" > /dev/null + + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + - run: uv build + - run: uvx twine check --strict dist/* diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..75f10a8 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,113 @@ +name: DCO + +# Every commit of a pull request must be signed off by its author, as DCO.md and +# CONTRIBUTING.md ask: a `Signed-off-by: Name ` trailer naming the commit's author. +# Require the `DCO` check on main; this workflow is the source of truth. +# +# pull_request_target runs the workflow as it is on main, never as a pull request edits it, +# so a contributor cannot change the check that judges their own commits. It is safe because +# nothing here checks out or runs the pull request's code: the check below reads the commits' +# metadata from the API, and it needs no other file, so it can be copied to any repository. +on: + pull_request_target: + branches: ["main"] + +permissions: + contents: read + pull-requests: read + +jobs: + dco: + name: DCO + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Every commit is signed off by its author + shell: python + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + """Fail when a commit of the pull request is not signed off by its author (DCO 1.1).""" + + import json + import os + import re + import sys + import urllib.request + + SIGN_OFF = re.compile( + r"^Signed-off-by:[ \t]*(?P.+?)[ \t]*<(?P[^<>]+)>[ \t]*$", re.M + ) + FIX = ( + "Sign commits off with `git commit -s`. To fix this pull request:\n\n" + " git commit --amend --signoff --no-edit # the latest commit\n" + " git rebase --signoff origin/main # every commit of the branch\n" + " git push --force-with-lease\n\n" + "See CONTRIBUTING.md and DCO.md." + ) + + + def problem(commit): + """Why a commit, as the API lists a pull request's commits, fails; None if it passes. + + A commit passes when one of its Signed-off-by trailers names its author, by name and + by email, ignoring case. A merge commit adds no work of its own, and a bot account + certifies nothing, so neither is checked. + """ + if len(commit.get("parents") or []) > 1: + return None + if (commit.get("author") or {}).get("type") == "Bot": + return None + author = commit["commit"]["author"] + signoffs = [(m["name"], m["email"]) for m in SIGN_OFF.finditer(commit["commit"]["message"])] + if any( + name.lower() == author["name"].lower() and email.lower() == author["email"].lower() + for name, email in signoffs + ): + return None + expected = f"{author['name']} <{author['email']}>" + if not signoffs: + return f"no Signed-off-by; expected {expected}" + found = ", ".join(f"{name} <{email}>" for name, email in signoffs) + return f"signed off by {found}, not by its author: expected {expected}" + + + def commits(): + """Every commit of the pull request, page by page.""" + api = os.environ.get("GITHUB_API_URL", "https://api.github.com") + repository, number = os.environ["GITHUB_REPOSITORY"], os.environ["PR_NUMBER"] + page = 1 + while True: + request = urllib.request.Request( + f"{api}/repos/{repository}/pulls/{number}/commits?per_page=100&page={page}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + batch = json.load(response) + yield from batch + if len(batch) < 100: + return + page += 1 + + + def main(): + listed = list(commits()) + failed = [(commit, why) for commit in listed if (why := problem(commit)) is not None] + for commit, why in failed: + subject = commit["commit"]["message"].splitlines()[0] + print(f"::error title=DCO::{commit['sha']} {subject}: {why}") + if failed: + print(f"\n{len(failed)} of {len(listed)} commits are not signed off by their author.") + print(f"\n{FIX}") + return 1 + print(f"All {len(listed)} commits are signed off by their author.") + return 0 + + + if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..956da60 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # PyPI trusted publishing: no API token is stored anywhere. + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + + - name: The tag names the package version + run: | + version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/kaitencloud/_version.py) + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "tag $GITHUB_REF_NAME does not match the package version $version" >&2 + exit 1 + fi + + - run: uv sync --locked + - run: uv run pytest + + - run: uv build + - run: uvx twine check --strict dist/* + - run: uv publish --trusted-publishing always diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87528bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ + +# Tooling +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +coverage.xml +htmlcov/ + +# What a playbook run recorded about itself +.kaiten-playbook.json + +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f927cb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html): until 1.0.0, a minor version may +change the public API, and a patch version never does. + +## [0.1.0] - Unreleased + +The first release of the Kaiten Python SDK, generated and tested against the contract of +[`kaitencloud/kaiten@fa7f554f`](https://github.com/kaitencloud/kaiten/commit/fa7f554fb6464f52c2a3f1ed487f4c7383c8125d). + +### Added + +- `KaitenClient` and `AsyncKaitenClient` for the Core API: customers, instances, licenses and + license families, entitlements and entitlement groups, usage reporting, feature flag + definitions, deployment zones, releases, components, metadata fields, service accounts, + connectors and integrations -- 98 of the contract's 107 operations. Of the nine left out, + three are the console's rule-editor tooling and four a signed-in person's notification feed + and preferences, which Kaiten's own SDK coverage excludes too; the other two are OFREP flag + evaluation, which belongs to OpenFeature (see below). +- `KaitenPlatformClient` and `AsyncKaitenPlatformClient` for the Platform API: all 10 operations. +- Configuration from arguments or from `KAITEN_BASE_URL` and `KAITEN_AUTH_TOKEN`. There is no + default API address: every deployment has its own, and the SDK never guesses where to send + a token. +- License versioning: `licenses.create()` opens a license family or adds its next version + (`family_slug`, `family_id`, `lifecycle_state`), `licenses.publish()`, `archive()` and + `unarchive()` move a version through its lifecycle, and `license_families.list()` and + `get()` resolve a family to the version it currently serves, or to a pinned one. +- Models for every response and every webhook event, generated from the contract with + pydantic v2: snake_case attributes, lenient about fields and enum values added after a + release, and `to_dict()` for the exact wire shape. +- `kaitencloud.types.Scope`, every scope an organization token can carry, generated from the + contract and used to type `service_accounts.create_token()` and `platform.tokens.mint()`. +- List methods that walk every page, and fail with `PaginationError` rather than return a list + the API said was incomplete. +- Automatic retries with exponential backoff for requests that are safe to repeat -- never a + usage report -- honouring `Retry-After`. +- Typed errors for every failure, carrying the RFC 9457 problem: `code`, `detail`, `error_id`. + `ThresholdExceededError` for a usage report refused at the license's cap. +- A credential guard that refuses a platform credential on the Core client, an organization + token on the platform client, and a webhook secret on either, before anything is sent. +- `kaitencloud.webhooks`: Svix signature verification, and a typed model for each of the 55 + published events. +- `kaitencloud.targeting`, builders for feature flag variants, rollouts and targeting rules. +- `kaitencloud.usage`, the enforcement arithmetic the API applies to a grant and its overage + allowance. +- `examples/playbook.py`, a resumable walk through a deployment of your own, step by step. + +### Not included, on purpose + +- Flag evaluation. Kaiten implements OFREP, so flags are evaluated with an OpenFeature SDK and + its generic OFREP provider (`openfeature-provider-ofrep`); the README shows how. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dccb2fb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing to Kaiten SDK for Python + +Thank you for contributing to the Kaiten Python SDK. + +This project is licensed under the Apache License, Version 2.0. + +## Developer Certificate of Origin + +Kaiten uses the Developer Certificate of Origin 1.1 (DCO) for contributions. + +Every commit must include a `Signed-off-by` trailer matching the commit author. + +Use: + +```bash +git commit -s -m "Describe your change" +``` + +This produces: + +```text +Signed-off-by: Jane Doe +``` + +`git commit -s` is a **DCO sign-off**: it certifies the contribution under +[DCO.md](./DCO.md). `git commit -S`, with a capital S, is a **cryptographic +commit signature** made with a GPG or SSH key. They are not the same thing, and +the sign-off is what this project requires. There is nothing to install. + +### Fixing a missing sign-off + +A check named **DCO** verifies every commit of every pull request, and it is the +final word. If it fails, it names the commits to fix. + +For the latest commit: + +```bash +git commit --amend --signoff --no-edit +git push --force-with-lease +``` + +For several commits, sign off every commit of your branch at once: + +```bash +git rebase --signoff origin/main +git push --force-with-lease +``` + +## Contribution rules + +Please: + +- keep pull requests focused; +- add or update tests when behavior changes; +- update documentation when relevant; +- do not include secrets, customer data, or confidential information; +- do not submit code or assets that you do not have the right to contribute; +- preserve required third-party license and attribution notices. + +Accepted contributions are contributed under Apache-2.0. + +## Before you open a pull request + +The development workflows are [Task](https://taskfile.dev) targets over +[uv](https://docs.astral.sh/uv/); the README's +[Development](./README.md#development) section lists them all. + +```bash +task setup # install the development environment +task check # everything CI runs +``` + +Every Python file opens with the project's license notice: + +```python +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 +``` + +The tests check it, generated files included. Never edit the generated +`src/kaitencloud/_sync/` or `src/kaitencloud/types/` modules by hand: change +the async source, the contract or a generator, then run `task generate`. + +## Security + +Do not report unpatched vulnerabilities in public issues. + +See [SECURITY.md](./SECURITY.md). diff --git a/DCO.md b/DCO.md new file mode 100644 index 0000000..8201f99 --- /dev/null +++ b/DCO.md @@ -0,0 +1,37 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + 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/NOTICE b/NOTICE new file mode 100644 index 0000000..b4df983 --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +Kaiten SDK for Python + +Copyright 2026 KAITEN INC diff --git a/README.md b/README.md index a750a3b..af1f845 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,811 @@ -# sdk-python -Kaiten python SDK +
+ +# Kaiten Python SDK + +**The official Python client for [Kaiten](https://kaiten.sh), the control plane of your SaaS:
+customers, instances, licenses, entitlements, usage metering, feature flags and releases.** + +[![PyPI](https://img.shields.io/pypi/v/kaitencloud.svg?label=pypi&color=4c1)](https://pypi.org/project/kaitencloud/) +[![Python](https://img.shields.io/pypi/pyversions/kaitencloud.svg)](https://pypi.org/project/kaitencloud/) +[![CI](https://github.com/kaitencloud/sdk-python/actions/workflows/ci.yml/badge.svg)](https://github.com/kaitencloud/sdk-python/actions/workflows/ci.yml) +[![Typed](https://img.shields.io/badge/typing-mypy%20strict-2a6db2.svg)](https://peps.python.org/pep-0561/) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) + +[Documentation](https://docs.kaiten.sh) · [Changelog](CHANGELOG.md) · [Report a bug](https://github.com/kaitencloud/sdk-python/issues) + +
+ +--- + +```python +from kaitencloud import KaitenClient + +client = KaitenClient(token="ksh_...") + +for customer in client.customers.list(): + print(customer.name) + +client.instances.report_usage("acme-production", "seats", 1) +``` + +## Why this SDK + +- **Typed end to end.** Every response is a pydantic model generated from Kaiten's OpenAPI + contract; the package ships `py.typed` and passes `mypy --strict`. +- **Sync and async, identical.** `KaitenClient` and `AsyncKaitenClient` expose the same methods + with the same signatures — the sync client is generated from the async one. +- **Complete lists.** `list()` walks every page for you, and refuses to hand back a list the API + said was incomplete. +- **Retries that never double-bill.** Only requests that are safe to repeat are retried. A usage + report is never sent twice. +- **Errors you can act on.** Every failure is a typed exception carrying the API's RFC 9457 + problem: `code`, `detail`, `error_id`. +- **Both APIs.** The Core API your product talks to, and the Platform API that provisions + organizations and mints their tokens. +- **Webhooks included.** Svix signatures verified, all 55 event types parsed into models. +- **Flags through OpenFeature.** Kaiten speaks OFREP, so flags are defined here and + evaluated with the [OpenFeature SDK](#feature-flags) — a vendor-neutral API, not ours. +- **Contract-tested.** All 108 operations the SDK wraps are exercised against the contract, and + every request body is validated against the API's own schema. + +## Contents + +- [Installation](#installation) +- [Quick start](#quick-start) +- [Configuration](#configuration) +- [Guide](#guide) + - [Catalog and licenses](#catalog-and-licenses) · [Customers and instances](#customers-and-instances) · [Usage metering](#usage-metering) + - [Feature flags](#feature-flags) · [Releases and deployment zones](#releases-and-deployment-zones) · [Webhooks](#webhooks) + - [The Platform API](#the-platform-api) +- [The playbook](#the-playbook) +- [Working with the SDK](#working-with-the-sdk): [pagination](#pagination) · [errors](#errors) · [retries and timeouts](#retries-and-timeouts) · [models](#models) · [logging](#logging) +- [API reference](#api-reference) +- [How the SDK stays true to the API](#how-the-sdk-stays-true-to-the-api) +- [Development](#development) + +## Installation + +```bash +pip install kaitencloud +``` + +or, with [uv](https://docs.astral.sh/uv/): + +```bash +uv add kaitencloud +``` + +The SDK supports Python 3.10 to 3.14, and depends only on `httpx`, `pydantic` v2 and +`typing-extensions`. + +## Quick start + +Create an organization token (`ksh_...`) from a service account in the Kaiten console, then +point the client at your deployment: + +```python +from kaitencloud import KaitenClient + +client = KaitenClient( + base_url="https://kaiten.example.com", # or set KAITEN_BASE_URL + token="ksh_...", # or set KAITEN_AUTH_TOKEN +) + +# Every customer, however many pages it takes. +for customer in client.customers.list(): + print(customer.slug, customer.name) + +# What an instance consumes, next to what its license grants. +for meter in client.instances.list_usage("acme-production"): + granted = meter.limit.value if meter.limit else "not granted" + print(f"{meter.entitlement_slug}: {meter.value.value} of {granted}") +``` + +With `KAITEN_BASE_URL` and `KAITEN_AUTH_TOKEN` set, a client needs no arguments, and the rest +of this README assumes they are. The async client is the same API, awaited: + +```python +import asyncio + +from kaitencloud import AsyncKaitenClient + + +async def main() -> None: + async with AsyncKaitenClient() as client: + customers, instances = await asyncio.gather( + client.customers.list(), + client.instances.list(), + ) + print(f"{len(customers)} customers, {len(instances)} instances") + + +asyncio.run(main()) +``` + +### The model in one picture + +```text +Organization you: a SaaS vendor using Kaiten +└── Customer client.customers a client of yours + └── Instance client.instances a deployment of your product for that customer + ├── License client.licenses the contract it runs under, granting values + │ client.entitlements ...of the entitlements in your catalog + ├── Usage client.instances.report_usage() what it consumed + └── Zone client.deployment_zones where it runs, and the release deployed there + client.releases → client.components +``` + +## Configuration + +| Argument | Environment variable | Default | | +| --- | --- | --- | --- | +| `token` | `KAITEN_AUTH_TOKEN` | *required* | A string, or a callable returning one before each request | +| `base_url` | `KAITEN_BASE_URL` | *required* | Your deployment's address; `/api` is appended when missing | +| `timeout` | | 30 s (10 s to connect) | Seconds, or an `httpx.Timeout` | +| `max_retries` | | `2` | Retries of a failed request that is safe to repeat | +| `default_headers` | | | Headers sent with every request | +| `http_client` | | | Your own `httpx.Client` / `httpx.AsyncClient`: proxies, TLS, transports | + +Both clients are context managers, and closing one closes the connection pool it created — +never one you passed in. + +### Credentials + +Kaiten has several kinds of credential, and each API refuses the others with a deliberately +vague error. The SDK recognizes them and raises a clear `CredentialError` *before* sending +anything when one ends up in the wrong place. + +| Credential | Looks like | Use it with | +| --- | --- | --- | +| Organization token | `ksh_...` | `KaitenClient` | +| Identity-provider JWT | `eyJ...` | `KaitenClient` | +| Platform credential | `ksm_...` | `KaitenPlatformClient` | +| Webhook signing secret | `whsec_...` | `kaitencloud.webhooks` | + +A credential that rotates can be supplied as a callable — it is read before every request, so +there is no client to rebuild: + +```python +client = KaitenClient(token=lambda: vault.read("kaiten/token")) +``` + +## Guide + +### Catalog and licenses + +An **entitlement** says what can be granted and how its usage is measured. A **license** grants +values of entitlements. A numeric grant carries its own enforcement: `overage_percent=0` is a +hard limit, `10` tolerates 10% over the granted value before usage reports are refused. + +```python +from kaitencloud import UNLIMITED, KaitenClient + +client = KaitenClient() + +client.entitlements.create( + name="Seats", + slug="seats", + type="NUMBER", + unit_singular="seat", + unit_plural="seats", + user_facing=True, +) +client.entitlements.create(name="Single sign-on", slug="sso", type="BOOLEAN") +client.entitlements.create(name="API calls", slug="api-calls", type="NUMBER", reset_period="MONTH") + +client.licenses.create(name="Growth", slug="growth", type="PAID") +client.licenses.associate_entitlement("growth", "seats", 50, overage_percent=10) +client.licenses.associate_entitlement("growth", "sso", True) +client.licenses.associate_entitlement("growth", "api-calls", UNLIMITED) +``` + +A granted value is a `bool` for a BOOLEAN entitlement, a number for a NUMBER one, and a `dict` +for a CONFIG one. + +A license is one **version** of a product, its **license family**. Creating a license without +naming a family opens a new one: `growth` above is version 1 of the family `growth`. To change +what a plan grants without touching the customers already on it, add a version to the family, +build it as a draft, and put it on sale when it is ready: + +```python +client.licenses.create(name="Growth", type="PAID", family_slug="growth", lifecycle_state="DRAFT") +client.licenses.associate_entitlement("growth-v2", "seats", 100, overage_percent=10) +client.licenses.publish("growth-v2") + +family = client.license_families.get("growth") # now resolves to growth-v2 +``` + +A new version's slug is `-v`, and its number is assigned by the server. A +family resolves to its default version if it has one, otherwise to its highest-numbered +published version — so store the family slug, which survives renames and new versions, rather +than a version's. `license_families.get("growth", version=1)` pins a version whatever its state, +and `include_versions=True` adds the whole history. `licenses.archive()` withdraws a version +from sale: the instances already on it keep it, and no new one can take it until +`licenses.unarchive()`. + +### Customers and instances + +```python +from datetime import datetime, timezone + +acme = client.customers.create(name="Acme", slug="acme", domain="acme.com") +growth = client.licenses.get("growth") + +client.instances.create( + name="Acme production", + slug="acme-production", + customer_id=acme.id, + license_id=growth.id, + start_license_date=datetime(2026, 9, 1, tzinfo=timezone.utc), + end_license_date=datetime(2027, 9, 1, tzinfo=timezone.utc), + metadata={"region": "eu-west-1"}, +) + +client.instances.update_status("acme-production", "DEGRADED") +client.instances.update_lifecycle_stage("acme-production", "AT_RISK") +``` + +> **Note** — `update()` methods are full replacements, as the API's `PUT` is: send every field +> you want to keep. Datetimes must be timezone-aware: the SDK refuses to guess the zone of a +> license term. + +### Usage metering + +```python +from kaitencloud import ThresholdExceededError + +try: + client.instances.report_usage("acme-production", "seats", 1) +except ThresholdExceededError as error: + # Refused at the cap: the granted value plus its overage allowance. Nothing was recorded. + print("Time to upgrade:", error.detail) +``` + +`behavior="append"` (the default) folds the value into the total through the entitlement's +aggregation method; `behavior="set"` replaces the total, which is also how to correct it. + +`kaitencloud.usage` computes the same boundaries the server enforces, to render a meter or check +a report before sending it: + +```python +from kaitencloud import usage + +grant = client.licenses.get_entitlement("growth", "seats") +current = client.instances.get_usage("acme-production", "seats") +included, used = grant.value.value, current.value.value + +usage.maximum_allowed_usage(included, grant.limit_cap_exceeded_overage_percent) # 55.0 +usage.remaining(included, used, grant.limit_cap_exceeded_overage_percent) # left before refusal +usage.percentage_used(included, used) # passes 100 once the allowance is in use +``` + +### Feature flags + +Define flags with the builders in `kaitencloud.targeting`. Targeting rules are CEL expressions, +evaluated in order; the first match wins. Kaiten fills the reserved `__kaiten` namespace itself: +evaluate with a customer's slug as the targeting key, and a rule can read the license and the +entitlements that customer holds. + +```python +from kaitencloud import targeting + +client.feature_flags.create( + name="New checkout", + slug="new-checkout", + type="boolean", + variants=[targeting.variant("on", True), targeting.variant("off", False)], + default_variant="off", + targetings=[ + targeting.rule("Growth plan", "__kaiten.license.familySlug == 'growth'", "on"), + targeting.split_rule("Progressive rollout", "true", {"on": 10, "off": 90}), + ], +) +``` + +**This SDK does not evaluate flags, on purpose.** Kaiten implements +[OFREP](https://openfeature.dev/specification/appendix-c), the OpenFeature Remote Evaluation +Protocol, so evaluation belongs to [OpenFeature](https://openfeature.dev) itself: install the +SDK and the generic OFREP provider, point it at the same base URL with the same token, and your +application talks to a vendor-neutral API it already knows. + +```bash +pip install openfeature-sdk openfeature-provider-ofrep +``` + +```python +from openfeature import api +from openfeature.contrib.provider.ofrep import OFREPProvider +from openfeature.evaluation_context import EvaluationContext + +api.set_provider( + OFREPProvider( + base_url="https://kaiten.example.com/api", # the provider appends /ofrep/v1/... + headers_factory=lambda: {"Authorization": "Bearer ksh_..."}, + ) +) + +flags = api.get_client("checkout") +context = EvaluationContext(targeting_key="acme", attributes={"region": "eu"}) + +if flags.get_boolean_value("new-checkout", False, context): + ... + +details = flags.get_boolean_details("new-checkout", False, context) +print(details.value, details.variant, details.reason) +``` + +The targeting key is the subject rollouts bucket on — a customer slug, a user id — and it is +what the reserved `__kaiten` namespace resolves against, so a rule can read the license and the +entitlements that subject holds. Splits are sticky: a subject keeps its variant from one +evaluation to the next. A failed evaluation returns the default you passed rather than raise, +which is OpenFeature's contract, not ours. + +`client.feature_flags.manifest()` returns the OpenFeature manifest — every flag, its type and +its default — for tooling that generates typed accessors from flag definitions. + +### Releases and deployment zones + +```python +gateway = client.components.create(name="api-gateway", version="v1.4.0") +release = client.releases.create(version="v1.4.0", component_ids=[gateway.id]) + +# Pointing a zone at a release records the deployment. +client.deployment_zones.update( + "eu-west-1", name="AWS eu-west-1", type="production", release_id=release.id +) +``` + +Releases are immutable: changing one means deleting it and creating another. + +### Webhooks + +Kaiten delivers webhooks through [Svix](https://www.svix.com). `verify_webhook()` checks the +signature and the timestamp of a delivery, then returns its event as a typed model: + +```python +import os + +from fastapi import FastAPI, Request, Response +from kaitencloud.webhooks import ( + CustomerCreatedEvent, + InstanceEntitlementCapExceededEvent, + WebhookVerificationError, + verify_webhook, +) + +app = FastAPI() + + +@app.post("/webhooks/kaiten") +async def kaiten_webhook(request: Request) -> Response: + try: + event = verify_webhook( + await request.body(), # the raw body: parsing it first would change the signed bytes + request.headers, + secret=os.environ["KAITEN_WEBHOOK_SECRET"], + ) + except WebhookVerificationError: + return Response(status_code=400) + + match event: + case InstanceEntitlementCapExceededEvent(data=cap): + alert_account_manager(cap.entitlement_slug, cap.overage) + case CustomerCreatedEvent(data=customer): + send_welcome(customer.name) + + return Response(status_code=204) +``` + +Every event the API publishes has a model named after it, from `ComponentCreatedEvent` to +`ReleaseDeletedEvent`. An event newer than the SDK you run parses as `UnknownWebhookEvent` +instead of failing. + +### The Platform API + +The Platform API administers a whole Kaiten deployment. It listens on its own port, on no +public route, and accepts only a platform credential (`ksm_...`). + +```python +from datetime import timedelta + +from kaitencloud import KaitenPlatformClient + +platform = KaitenPlatformClient(base_url="http://kaiten-api.kaiten.svc:6001", token="ksm_...") + +# Idempotent: the organization's id derives from the identity provider's id. +org = platform.organizations.ensure(external_id="org_2abcDEF", name="Acme") + +# A platform credential never acts inside an organization: it mints a token that does. +minted = platform.tokens.mint( + org.id, name="billing-sync-v2", scopes=["write:instances"], ttl=timedelta(days=90) +) +store_secret(minted.token) # the plaintext exists in this response only +``` + +> **Revocation cascades.** Revoking a platform credential revokes every token it minted, so +> rotate by minting the new token, adopting it, then revoking the old one. And `revoke()` takes +> the token's server-generated **slug**, which `tokens.list()` maps back from the name you chose. + +```python +old = next(token for token in platform.tokens.list(org.id) if token.name == "billing-sync-v1") +platform.tokens.revoke(org.id, old.slug) +``` + +A connector hosted outside Kaiten registers itself on every start — registration is an upsert: + +```python +platform.connectors.register( + name="kaiten.integration.crm.acme", + version="1.2.0", + settings_schema={ + "type": "object", + "properties": { + "apiKey": {"type": "string", "writeOnly": True} + }, # a secret: never read back + }, + entitlement_slug="crm-sync", # the BOOLEAN entitlement a license must grant to activate it +) +``` + +## The playbook + +[`examples/playbook.py`](examples/playbook.py) is a guided tour of a deployment of your own: +thirteen steps that build a catalog, a license, a customer and an instance, meter usage until the +license refuses a report, evaluate a feature flag, mint and revoke a token and verify a webhook +signature — then delete everything they made. Each step prints the call before it makes it, so +the script reads as documentation that runs, one step at a time. + +```bash +uv run python examples/playbook.py --list +``` + +[**`examples/README.md`**](examples/README.md) is the guide to it: what each step covers, how to +pause and resume a run, what it records about itself, and how `cleanup` deletes only what that +run created. + +## Working with the SDK + +### Pagination + +Every `list()` returns the **whole** collection: the SDK walks the cursor at the largest page +size the API accepts. If the API ever reports more rows without a usable cursor, `list()` raises +`PaginationError` rather than return a list it knows is partial. + +The audit trail grows without bound, so it takes a ceiling instead: + +```python +entries = client.instances.list_audit_trails( + "acme-production", event_name="INSTANCE_STATUS_CHANGED", limit=100 +) +``` + +### Errors + +Every exception derives from `KaitenError`: + +```text +KaitenError +├── CredentialError a credential is missing, or in the wrong place (raised before sending) +├── APIConnectionError the API could not be reached +│ └── APITimeoutError +├── APIStatusError the API answered with a non-2xx status +│ ├── BadRequestError 400 +│ ├── AuthenticationError 401 missing, expired or revoked credential +│ ├── PermissionDeniedError 403 a scope is missing +│ ├── NotFoundError 404 +│ ├── ConflictError 409 +│ │ └── ThresholdExceededError a usage report refused at the cap +│ ├── UnprocessableEntityError 422 +│ ├── RateLimitError 429 +│ ├── InternalServerError 5xx +│ │ └── ServiceUnavailableError 503 +│ └── FlagEvaluationError a flag could not be evaluated +├── APIResponseValidationError a 2xx response that breaks the contract +├── PaginationError a list the API reported as incomplete +├── WebhookVerificationError a webhook that fails verification +└── WebhookPayloadError an authentic webhook that breaks its event contract +``` + +An `APIStatusError` keeps everything the API said. Branch on `code` rather than on the status: +a 409 for a reached limit and a 409 for a taken slug share a status, not a meaning. + +```python +from kaitencloud import APIStatusError + +try: + client.customers.create(name="Acme", slug="acme") +except APIStatusError as error: + print(error.status_code, error.code, error.detail) + print("Quote this in a support request:", error.error_id) +``` + +### Retries and timeouts + +A request that fails on a connection error, a 408, a 429 or a 5xx is retried with exponential +backoff and jitter, honouring `Retry-After` — but only when repeating it is safe: + +| Request | Retried | +| --- | --- | +| `GET`, `PUT`, `DELETE` | ✓ | +| Flag evaluations, instance status and lifecycle updates, metadata dry runs and reorders, `organizations.ensure()`, `connectors.register()` | ✓ — they converge | +| Any other `POST` or `PATCH`: creations, usage reports, upserts | only if the connection never opened | +| A `401` or `403` | never | + +Tune it per client, or per call with `with_options()`, which shares the connection pool: + +```python +import httpx + +client = KaitenClient(timeout=10.0, max_retries=4) +client.with_options(timeout=1.0, max_retries=0).customers.get("acme") + +proxied = KaitenClient(http_client=httpx.Client(proxy="http://proxy.internal:3128")) +``` + +### Models + +Responses are pydantic models with snake_case attributes. They are lenient where the API grows: +a field added after your SDK release lands in `model_extra`, and an unknown enum value parses as a +plain string instead of failing. + +```python +instance = client.instances.get("acme-production") + +instance.customer_slug # typed attributes +instance.start_license_date # a timezone-aware datetime +instance.to_dict() # the exact wire shape, camelCase keys included +instance.model_extra # fields newer than this SDK +``` + +All models, and the typed dictionaries methods accept, are importable from `kaitencloud.types`. + +### Logging + +The SDK logs to the `kaitencloud` logger: retries at `INFO`, flag evaluations that fell back to +their default at `WARNING`. Credentials are never logged. + +```python +import logging + +logging.getLogger("kaitencloud").setLevel(logging.INFO) +``` + +## API reference + +The Core API clients cover 98 of its 107 operations. Of the nine left out, three are +rule-editor tooling for the Kaiten console, four are a signed-in person's own notification feed +and preferences — which an API token has no equivalent of — and two are the OFREP evaluation +endpoints, which an [OpenFeature provider](#feature-flags) calls. The Platform API clients cover +all 10 of its operations. The async clients have the same methods. + +
+client.components · client.connectors · client.customers + +| Method | Operation | +| --- | --- | +| `client.components.list()` | `GET /components` | +| `client.components.get()` | `GET /components/{componentSlug}` | +| `client.components.create()` | `POST /components` | +| `client.components.update()` | `PUT /components/{componentSlug}` | +| `client.components.delete()` | `DELETE /components/{componentSlug}` | +| `client.connectors.list()` | `GET /connectors` | +| `client.connectors.get()` | `GET /connectors/{connectorName}` | +| `client.connectors.get_state()` | `GET /connectors/{connectorName}/state` | +| `client.connectors.activate()` | `PUT /connectors/{connectorName}/activation` | +| `client.connectors.deactivate()` | `DELETE /connectors/{connectorName}/activation` | +| `client.connectors.get_settings_schema()` | `GET /connectors/{connectorName}/settings/schema` | +| `client.connectors.get_settings()` | `GET /connectors/{connectorName}/settings` | +| `client.connectors.update_settings()` | `PUT /connectors/{connectorName}/settings` | +| `client.connectors.delete_settings()` | `DELETE /connectors/{connectorName}/settings` | +| `client.customers.list()` | `GET /customers` | +| `client.customers.get()` | `GET /customers/{customerSlug}` | +| `client.customers.create()` | `POST /customers` | +| `client.customers.update()` | `PUT /customers/{customerSlug}` | +| `client.customers.delete()` | `DELETE /customers/{customerSlug}` | +| `client.customers.get_integration()` | `GET /customers/{customerSlug}/integrations/{integrationName}` | +| `client.customers.create_integration()` | `POST /customers/{customerSlug}/integrations/{integrationName}` | +| `client.customers.update_integration()` | `PUT /customers/{customerSlug}/integrations/{integrationName}` | +| `client.customers.delete_integration()` | `DELETE /customers/{customerSlug}/integrations/{integrationName}` | + +
+ +
+client.deployment_zones · client.entitlement_groups · client.entitlements + +| Method | Operation | +| --- | --- | +| `client.deployment_zones.list()` | `GET /deployment-zones` | +| `client.deployment_zones.get()` | `GET /deployment-zones/{deploymentZoneSlug}` | +| `client.deployment_zones.create()` | `POST /deployment-zones` | +| `client.deployment_zones.update()` | `PUT /deployment-zones/{deploymentZoneSlug}` | +| `client.deployment_zones.delete()` | `DELETE /deployment-zones/{deploymentZoneSlug}` | +| `client.entitlement_groups.list()` | `GET /entitlement-groups` | +| `client.entitlement_groups.get()` | `GET /entitlement-groups/{entitlementGroupSlug}` | +| `client.entitlement_groups.create()` | `POST /entitlement-groups` | +| `client.entitlement_groups.update()` | `PUT /entitlement-groups/{entitlementGroupSlug}` | +| `client.entitlement_groups.delete()` | `DELETE /entitlement-groups/{entitlementGroupSlug}` | +| `client.entitlement_groups.add_entitlement()` | `POST /entitlement-groups/{entitlementGroupSlug}/entitlements` | +| `client.entitlement_groups.remove_entitlement()` | `DELETE /entitlement-groups/{entitlementGroupSlug}/entitlements/{entitlementSlug}` | +| `client.entitlement_groups.get_usage()` | `GET /entitlement-groups/{entitlementGroupSlug}/usage` | +| `client.entitlements.list()` | `GET /entitlements` | +| `client.entitlements.get()` | `GET /entitlements/{entitlementSlug}` | +| `client.entitlements.create()` | `POST /entitlements` | +| `client.entitlements.update()` | `PUT /entitlements/{entitlementSlug}` | +| `client.entitlements.delete()` | `DELETE /entitlements/{entitlementSlug}` | + +
+ +
+client.feature_flags · client.flags + +| Method | Operation | +| --- | --- | +| `client.feature_flags.list()` | `GET /feature-flags` | +| `client.feature_flags.get()` | `GET /feature-flags/{featureFlagSlug}` | +| `client.feature_flags.create()` | `POST /feature-flags` | +| `client.feature_flags.update()` | `PUT /feature-flags/{featureFlagSlug}` | +| `client.feature_flags.delete()` | `DELETE /feature-flags/{featureFlagSlug}` | +| `client.feature_flags.manifest()` | `GET /openfeature/v0/manifest` | + +
+ +
+client.instances · client.integrations + +| Method | Operation | +| --- | --- | +| `client.instances.list()` | `GET /instances` | +| `client.instances.get()` | `GET /instances/{instanceSlug}` | +| `client.instances.create()` | `POST /instances` | +| `client.instances.update()` | `PUT /instances/{instanceSlug}` | +| `client.instances.update_status()` | `PATCH /instances/{instanceSlug}` | +| `client.instances.update_lifecycle_stage()` | `PATCH /instances/{instanceSlug}` | +| `client.instances.delete()` | `DELETE /instances/{instanceSlug}` | +| `client.instances.list_audit_trails()` | `GET /instances/{instanceSlug}/audit-trails` | +| `client.instances.list_usage()` | `GET /instances/{instanceSlug}/entitlements/usage` | +| `client.instances.get_usage()` | `GET /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage` | +| `client.instances.report_usage()` | `POST /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage` | +| `client.instances.get_integration()` | `GET /instances/{instanceSlug}/integrations/{integrationName}` | +| `client.instances.create_integration()` | `POST /instances/{instanceSlug}/integrations/{integrationName}` | +| `client.instances.update_integration()` | `PUT /instances/{instanceSlug}/integrations/{integrationName}` | +| `client.instances.delete_integration()` | `DELETE /instances/{instanceSlug}/integrations/{integrationName}` | +| `client.integrations.get_customer()` | `GET /integration/{adapter}/customer/{externalId}` | +| `client.integrations.upsert_customer()` | `PATCH /integration/{adapter}/customer/{externalId}` | +| `client.integrations.get_instance()` | `GET /integration/{adapter}/instance/{externalId}` | +| `client.integrations.upsert_instance()` | `PATCH /integration/{adapter}/instance/{externalId}` | + +
+ +
+client.licenses · client.metadata_fields + +| Method | Operation | +| --- | --- | +| `client.license_families.list()` | `GET /license-families` | +| `client.license_families.get()` | `GET /license-families/{familySlug}` | +| `client.licenses.list()` | `GET /licenses` | +| `client.licenses.get()` | `GET /licenses/{licenseSlug}` | +| `client.licenses.create()` | `POST /licenses` | +| `client.licenses.update()` | `PUT /licenses/{licenseSlug}` | +| `client.licenses.delete()` | `DELETE /licenses/{licenseSlug}` | +| `client.licenses.publish()` | `POST /licenses/{licenseSlug}/publish` | +| `client.licenses.archive()` | `POST /licenses/{licenseSlug}/archive` | +| `client.licenses.unarchive()` | `POST /licenses/{licenseSlug}/unarchive` | +| `client.licenses.list_entitlements()` | `GET /licenses/{licenseSlug}/entitlements` | +| `client.licenses.get_entitlement()` | `GET /licenses/{licenseSlug}/entitlements/{entitlementSlug}` | +| `client.licenses.associate_entitlement()` | `POST /licenses/{licenseSlug}/entitlements` | +| `client.licenses.update_entitlement()` | `PUT /licenses/{licenseSlug}/entitlements/{entitlementSlug}` | +| `client.licenses.delete_entitlement()` | `DELETE /licenses/{licenseSlug}/entitlements/{entitlementSlug}` | +| `client.metadata_fields.list()` | `GET /metadata-fields` | +| `client.metadata_fields.create()` | `POST /metadata-fields` | +| `client.metadata_fields.update()` | `PATCH /metadata-fields/{id}` | +| `client.metadata_fields.archive()` | `POST /metadata-fields/{id}/archive` | +| `client.metadata_fields.unarchive()` | `POST /metadata-fields/{id}/unarchive` | +| `client.metadata_fields.dry_run()` | `POST /metadata-fields/{id}/dry-run` | +| `client.metadata_fields.reorder()` | `POST /metadata-fields/reorder` | + +
+ +
+client.releases · client.service_accounts + +| Method | Operation | +| --- | --- | +| `client.releases.list()` | `GET /releases` | +| `client.releases.get()` | `GET /releases/{releaseSlug}` | +| `client.releases.create()` | `POST /releases` | +| `client.releases.delete()` | `DELETE /releases/{releaseSlug}` | +| `client.service_accounts.list()` | `GET /service-accounts` | +| `client.service_accounts.get()` | `GET /service-accounts/{serviceAccountSlug}` | +| `client.service_accounts.create()` | `POST /service-accounts` | +| `client.service_accounts.update()` | `PUT /service-accounts/{serviceAccountSlug}` | +| `client.service_accounts.list_tokens()` | `GET /service-accounts/{serviceAccountSlug}/tokens` | +| `client.service_accounts.create_token()` | `POST /service-accounts/{serviceAccountSlug}/tokens` | +| `client.service_accounts.delete_token()` | `DELETE /service-accounts/{serviceAccountSlug}/tokens/{tokenSlug}` | + +
+ +
+Platform API — KaitenPlatformClient + +| Method | Operation | +| --- | --- | +| `platform.me()` | `GET /platform/me` | +| `platform.connectors.register()` | `POST /platform/connectors` | +| `platform.organizations.ensure()` | `POST /platform/organizations` | +| `platform.organizations.get()` | `GET /platform/organizations/{orgId}` | +| `platform.organizations.delete()` | `DELETE /platform/organizations/{orgId}` | +| `platform.organizations.delete_membership()` | `DELETE /platform/organizations/{orgId}/memberships/{userId}` | +| `platform.tokens.mint()` | `POST /platform/organizations/{orgId}/tokens` | +| `platform.tokens.list()` | `GET /platform/organizations/{orgId}/tokens` | +| `platform.tokens.revoke()` | `DELETE /platform/organizations/{orgId}/tokens/{tokenSlug}` | +| `platform.users.delete()` | `DELETE /platform/users/{id}` | + +
+ +## How the SDK stays true to the API + +An SDK written by hand drifts from the API it wraps, one renamed field at a time — and a request +body with one key too many is refused on every call. This one is built so that it cannot drift +silently: + +1. **The contract is vendored.** [`openapi/`](openapi/) holds a snapshot of Kaiten's two OpenAPI + documents, copied from [kaitencloud/kaiten](https://github.com/kaitencloud/kaiten) by + `task sync:openapi`, with the exact commit recorded in `openapi/source.yaml`. +2. **Models are generated from it**, by `scripts/generate_models.py`. +3. **Every operation is accounted for.** `openapi/coverage.yaml` maps each operation to the + method that calls it; the tests fail when an operation is left unwrapped or a method maps to + nothing. +4. **Every method is tested against the contract.** Each one is called against a fake API that + answers like the contract says, and every request body it sends is validated against the + operation's JSON Schema — once with all arguments, once with only the required ones. +5. **One implementation, two clients.** The sync client is generated from the async source by + `scripts/unasync.py`, and the tests check both have the same signatures. + +CI checks all of it on every change, on Python 3.10 to 3.14 and on the oldest supported +versions of the dependencies. + +## Development + +The workflows are [Task](https://taskfile.dev) targets over [uv](https://docs.astral.sh/uv/): + +```bash +task setup # install the development environment +task check # everything CI runs +``` + +| Task | What it does | +| --- | --- | +| `task test` | Run the test suite with coverage | +| `task lint` / `task fmt` | Check or fix linting and formatting (ruff) | +| `task typecheck` | `mypy --strict` over the package | +| `task generate` | Regenerate the models and the sync client | +| `task sync:openapi` | Copy the contract from a kaiten checkout (`KAITEN_PATH`, default `../kaiten`), then regenerate | +| `task build` | Build the sdist and the wheel, and check their metadata | + +[The playbook](#the-playbook) is part of the test suite: every step runs against the fake API, +so a renamed method or an argument the contract no longer accepts fails in CI rather than +halfway through somebody's walkthrough. + +Never edit `src/kaitencloud/_sync/` or the generated `src/kaitencloud/types/` modules by hand: +change the async source, the contract or a generator, then run `task generate`. + +Contributions are welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says how, and every commit carries +a [DCO](DCO.md) sign-off (`git commit -s`). Report a vulnerability privately, as +[SECURITY.md](SECURITY.md) describes, never in a public issue. + +## Versioning + +The SDK follows [Semantic Versioning](https://semver.org). Until 1.0.0, a minor release may +change the public API — the [changelog](CHANGELOG.md) says how — and a patch release never does. + +## License + +Kaiten SDK for Python is open source and licensed under the +[Apache License, Version 2.0](./LICENSE). + +By contributing, you agree to certify your contribution under the +[Developer Certificate of Origin 1.1](./DCO.md). + +The Kaiten name and logos are not licensed under Apache-2.0. +See the Kaiten trademark policy in the main Kaiten repository. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..314c5db --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +Please do not report security vulnerabilities through public GitHub issues. + +Report them privately, through either channel: + +- GitHub Private Vulnerability Reporting for this repository, from its + **Security** tab, when available; +- email to [security@kaiten.sh](mailto:security@kaiten.sh), which also suits + reporters without a GitHub account. + +Include, where possible: + +- affected version or commit; +- vulnerability description; +- reproduction steps or proof of concept; +- expected impact; +- known mitigations. + +KAITEN INC asks reporters to allow reasonable time to investigate and address +a vulnerability before public disclosure. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..8445aaf --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,74 @@ +# yaml-language-server: $schema=https://taskfile.dev/schema.json +version: "3" + +vars: + # The kaiten checkout `task sync:openapi` copies the contract from. + KAITEN_PATH: '{{.KAITEN_PATH | default "../kaiten"}}' + KAITEN_REF: '{{.KAITEN_REF | default "origin/main"}}' + +tasks: + default: + desc: List all available tasks + silent: true + cmds: + - task --list + + setup: + desc: Install the development environment + cmds: + - uv sync + + generate: + desc: Regenerate the models from openapi/ and the sync client from the async one + cmds: + - uv run python scripts/generate_models.py + - uv run python scripts/unasync.py + + generate:check: + desc: Fail when a generated file is stale + cmds: + - uv run python scripts/generate_models.py --check + - uv run python scripts/unasync.py --check + + sync:openapi: + desc: Copy the contract from a kaiten checkout (KAITEN_PATH, KAITEN_REF), then regenerate + cmds: + - uv run python scripts/sync_openapi.py --kaiten "{{.KAITEN_PATH}}" --ref "{{.KAITEN_REF}}" + - task: generate + + fmt: + desc: Format the code and apply safe lint fixes + cmds: + - uv run ruff format . + - uv run ruff check --fix . + + lint: + desc: Lint, and check formatting + cmds: + - uv run ruff check . + - uv run ruff format --check . + + typecheck: + desc: Type-check the package with mypy --strict + cmds: + - uv run mypy + + test: + desc: Run the test suite with coverage + cmds: + - uv run pytest --cov --cov-report=term-missing {{.CLI_ARGS}} + + check: + desc: Everything CI runs + cmds: + - task: generate:check + - task: lint + - task: typecheck + - task: test + + build: + desc: Build the sdist and the wheel, and check their metadata + cmds: + - rm -rf dist + - uv build + - uvx twine check --strict dist/* diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c05bc95 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,127 @@ +# The playbook + +[`playbook.py`](playbook.py) is a guided tour of a Kaiten deployment: one script that walks the +API from end to end against a deployment of your own. It builds a catalog, a license, a customer +and an instance, meters usage until the license refuses a report, evaluates a feature flag, mints +and revokes a token, verifies a webhook signature — then deletes everything it made. + +Every step prints the call it is about to make before making it, so the script reads as +documentation that runs, and a failure points at the one call that failed. + +> **It writes real data** into the organization its token belongs to. Point it at a development +> deployment, not at production. + +## Before you start + +Run it from the repository root, with a credential that may write: + +```bash +export KAITEN_BASE_URL=http://localhost:6000 +export KAITEN_AUTH_TOKEN=ksh_... # an organization token +``` + +The `flags` step evaluates through OpenFeature, since [the SDK does not evaluate flags +itself](../README.md#feature-flags) — Kaiten speaks OFREP, so the generic provider does the +evaluating: + +```bash +uv pip install openfeature-sdk openfeature-provider-ofrep +``` + +Without it, that step says so and moves on. Everything else runs on the SDK alone. + +## The steps + +```bash +uv run python examples/playbook.py --list +``` + +```text + 1. connect Read every collection, and see what the credential may do + 2. catalog An entitlement group, and the entitlements it holds + 3. license A license, the values it grants, and a second version of it + 4. customer A customer, and its link to a CRM + 5. product A component, a release, and the zone it is deployed to + 6. metadata A typed metadata field instances must satisfy + 7. instance An instance of your product, for that customer + 8. usage Meter usage, until the license refuses a report + 9. flags A feature flag, defined here and evaluated through OpenFeature + 10. tokens A service account, and a token that works until it is revoked + 11. webhooks Verify a delivery, with nothing on the network + 12. platform The Platform API, if a ksm_ credential is configured + 13. cleanup Delete everything this run created +``` + +They build on each other: the license grants what the catalog defined, the instance belongs to +the customer and to that license, and the usage step meters against the instance. Run them in +order the first time. + +The `license` step also drafts a second version of the license in the same family, then walks it +through its lifecycle — published, archived, put back on sale — reading the family after each +move to show which version it serves. The instance stays on version 1 throughout. + +## Taking it slowly + +`--pause` stops before each step and waits for Enter, which is how to read it the first time: + +```bash +uv run python examples/playbook.py --pause +``` + +Stop whenever you like. A later session picks up where you left off, because the run remembers +what it created: + +```bash +uv run python examples/playbook.py --from usage +``` + +Or run just the steps you care about, against what the earlier ones left behind: + +```bash +uv run python examples/playbook.py flags tokens +``` + +Then hand it all back: + +```bash +uv run python examples/playbook.py cleanup +``` + +## What a run leaves behind + +Every resource is named after the run — `playbook-a1b2-acme`, `playbook-a1b2-seats` — and every +one of them is recorded in `.kaiten-playbook.json` as it is created. That file is what makes +`--from` possible, and it is what `cleanup` reads: **cleanup deletes only what that file +records**, never a resource it did not create. It is also never included by accident — neither a +bare run nor `--from` runs it — so you have to ask for it. + +Two things cannot be deleted, and the playbook says so rather than pretend otherwise: a metadata +field is archived (which frees its key), and a service account has no delete endpoint, so it +stays behind with no live token. + +Running the playbook twice gives two runs with two prefixes, which do not collide. Delete the +state file to start fresh. + +## Options + +| Option | What it does | +| --- | --- | +| `--list` | Print the steps and exit — the one invocation that needs no credential | +| `--pause` | Wait for Enter before each step | +| `--from STEP` | Resume at a step and run to the end | +| `STEP [STEP ...]` | Run only these steps | +| `--base-url`, `--token` | Instead of `KAITEN_BASE_URL` and `KAITEN_AUTH_TOKEN` | +| `--platform-org UUID` | The organization the `platform` step mints a token in | +| `--state PATH` | Where the run is recorded (default `.kaiten-playbook.json`) | +| `--verbose` | Log retries and fallbacks on the `kaitencloud` logger | + +The `platform` step skips itself unless `KAITEN_PLATFORM_BASE_URL` and `KAITEN_PLATFORM_TOKEN` +are set: the Platform API has its own listener, reachable only from inside your infrastructure. + +## How it is kept honest + +`tests/test_playbook.py` runs every step against the same contract-backed fake the rest of the +suite uses, and `mypy --strict` covers this directory. So a method renamed in the SDK, or an +argument the contract no longer accepts, fails in CI rather than halfway through your +walkthrough — which is how the flag step's OpenFeature wiring and a typing defect in +`kaitencloud.targeting` were both caught. diff --git a/examples/playbook.py b/examples/playbook.py new file mode 100644 index 0000000..9d21310 --- /dev/null +++ b/examples/playbook.py @@ -0,0 +1,1103 @@ +#!/usr/bin/env python3 +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""A scripted walk through a Kaiten deployment, one step at a time. + +The playbook builds a small but complete world -- a catalog, a license, a customer, a release, +an instance, a feature flag -- meters usage against it until the license refuses a report, and +can then delete everything it made. + +Every resource it creates is named after the run (``playbook--...``), so it never touches +anything else, and everything it creates is written to a state file, so you can stop after any +step and pick up later:: + + uv run python examples/playbook.py --list # the steps, in order + uv run python examples/playbook.py # run them all + uv run python examples/playbook.py --pause # stop before each one + uv run python examples/playbook.py --from usage # resume at a step + uv run python examples/playbook.py catalog license # run only these + uv run python examples/playbook.py cleanup # delete what this run created + +It reads the same environment as the SDK -- ``KAITEN_BASE_URL`` and ``KAITEN_AUTH_TOKEN``, plus +``KAITEN_PLATFORM_BASE_URL`` and ``KAITEN_PLATFORM_TOKEN`` for the platform step -- or takes +``--base-url`` and ``--token``. + +It writes real data into the organization your token belongs to. Point it at a development +deployment, and run ``cleanup`` when you are done. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import logging +import os +import random +import string +import sys +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar + +from kaitencloud import ( + UNLIMITED, + APIStatusError, + AuthenticationError, + ConflictError, + CredentialError, + KaitenClient, + KaitenPlatformClient, + NotFoundError, + PermissionDeniedError, + ThresholdExceededError, + targeting, +) +from kaitencloud import usage as enforcement +from kaitencloud.types import EntitlementUsage +from kaitencloud.types import NumberEntitlementValue as Number +from kaitencloud.webhooks import ( + InstanceEntitlementCapExceededEvent, + WebhookVerificationError, + verify_webhook, +) + +if TYPE_CHECKING: # the OpenFeature SDK is the flag client; see the flags step + from openfeature.client import OpenFeatureClient + +T = TypeVar("T") + +STATE_FILE = Path(".kaiten-playbook.json") +ADAPTER = "playbook-crm" + + +# -------------------------------------------------------------------------------------------- +# Output +# -------------------------------------------------------------------------------------------- + +COLOR = sys.stdout.isatty() and "NO_COLOR" not in os.environ + + +def paint(text: str, code: str) -> str: + return f"\033[{code}m{text}\033[0m" if COLOR else text + + +def heading(text: str) -> None: + print(paint(f"\n{text}", "1;36")) + + +def call(code: str) -> None: + """Show the SDK call a step is about to make.""" + print(" " + paint(code, "2")) + + +def note(text: str) -> None: + print(f" {text}") + + +def done(text: str) -> None: + print(" " + paint("+ ", "32") + text) + + +def warn(text: str) -> None: + print(" " + paint("! ", "33") + text) + + +# -------------------------------------------------------------------------------------------- +# State: what this run created, so a later step -- or a later day -- can pick it up +# -------------------------------------------------------------------------------------------- + + +@dataclass +class State: + path: Path + data: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.path.exists(): + self.data = json.loads(self.path.read_text(encoding="utf-8")) + + def get(self, key: str, default: Any = None) -> Any: + return self.data.get(key, default) + + def set(self, key: str, value: Any) -> None: + self.data[key] = value + self.save() + + def save(self) -> None: + self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n", "utf-8") + + def clear(self) -> None: + self.data = {} + self.path.unlink(missing_ok=True) + + +@dataclass +class Playbook: + """What every step is handed: the clients, and the memory of what the run created.""" + + client: KaitenClient + state: State + platform: KaitenPlatformClient | None = None + platform_org: str | None = None + client_factory: Callable[[str], KaitenClient] | None = None + token: str | None = None + """The credential the OpenFeature provider sends, since it is not this SDK's client.""" + + @property + def run(self) -> str: + """The id of this run, minted once and kept in the state file.""" + run: str | None = self.state.get("run") + if not run: + run = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + self.state.set("run", run) + return run + + def slug(self, name: str) -> str: + return f"playbook-{self.run}-{name}" + + def title(self, name: str) -> str: + return f"Playbook {self.run}: {name}" + + def track(self, kind: str, identifier: str) -> None: + """Remember a created resource, so cleanup deletes it and only it.""" + created: dict[str, list[str]] = self.state.get("created", {}) + created.setdefault(kind, []) + if identifier not in created[kind]: + created[kind].append(identifier) + self.state.set("created", created) + + def created(self, kind: str) -> list[str]: + created: dict[str, list[str]] = self.state.get("created", {}) + return list(created.get(kind, [])) + + def new_client(self, token: str) -> KaitenClient: + """A second client, for trying a credential this run minted.""" + if self.client_factory is not None: + return self.client_factory(token) + return KaitenClient(base_url=self.client.base_url, token=token, max_retries=0) + + +class PlaybookError(RuntimeError): + """The deployment answered something the playbook cannot continue from.""" + + +def required(value: T | None, what: str) -> T: + if value is None: + raise PlaybookError(f"the API did not return {what}") + return value + + +def ensure(what: str, create: Callable[[], T]) -> T | None: + """Create something, and shrug when this run already did.""" + try: + created = create() + except ConflictError: + note(f"{what} is already there") + return None + done(f"created {what}") + return created + + +def amount(usage: EntitlementUsage) -> float | None: + """The numeric usage in a reading, when there is one.""" + return usage.value.value if isinstance(usage.value, Number) else None + + +# -------------------------------------------------------------------------------------------- +# Steps +# -------------------------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Step: + name: str + title: str + run: Callable[[Playbook], None] + + +STEPS: list[Step] = [] + + +def step( + name: str, title: str +) -> Callable[[Callable[[Playbook], None]], Callable[[Playbook], None]]: + def register(function: Callable[[Playbook], None]) -> Callable[[Playbook], None]: + STEPS.append(Step(name, title, function)) + return function + + return register + + +@step("connect", "Read every collection, and see what the credential may do") +def connect(book: Playbook) -> None: + client = book.client + note(f"base URL {client.base_url}") + call("client..list() # each one walks every page") + + readers: dict[str, Callable[[], Sequence[object]]] = { + "customers": client.customers.list, + "instances": client.instances.list, + "license families": client.license_families.list, + "licenses": client.licenses.list, + "entitlements": client.entitlements.list, + "entitlement groups": client.entitlement_groups.list, + "feature flags": client.feature_flags.list, + "deployment zones": client.deployment_zones.list, + "releases": client.releases.list, + "components": client.components.list, + "service accounts": client.service_accounts.list, + "connectors": client.connectors.list, + "metadata fields (instances)": lambda: client.metadata_fields.list("INSTANCE"), + } + for label, read in readers.items(): + try: + note(f"{len(read()):>5} {label}") + except PermissionDeniedError: + warn(f"{'--':>5} {label}: this credential has no scope for it") + done("the API answers, and the run can start") + + +@step("catalog", "An entitlement group, and the entitlements it holds") +def catalog(book: Playbook) -> None: + client = book.client + group, seats, calls, sso = ( + book.slug("quotas"), + book.slug("seats"), + book.slug("api-calls"), + book.slug("sso"), + ) + + call("client.entitlement_groups.create(name=..., slug=..., description=...)") + ensure( + f"entitlement group {group}", + lambda: client.entitlement_groups.create( + name=book.title("Quotas"), slug=group, description="What the playbook meters" + ), + ) + book.track("entitlement_groups", group) + + call('client.entitlements.create(name=..., type="NUMBER", unit_singular="seat", ...)') + ensure( + f"entitlement {seats}", + lambda: client.entitlements.create( + name=book.title("Seats"), + slug=seats, + type="NUMBER", + description="Named users", + aggregation_method="SUM", + unit_singular="seat", + unit_plural="seats", + user_facing=True, + display_order=1, + warning_threshold_percent=80, + group_slugs=[group], + ), + ) + book.track("entitlements", seats) + + call('client.entitlements.create(..., reset_period="MONTH", reset_anchor="CALENDAR")') + ensure( + f"entitlement {calls}", + lambda: client.entitlements.create( + name=book.title("API calls"), + slug=calls, + type="NUMBER", + description="Calls, counted per calendar month", + aggregation_method="SUM", + reset_period="MONTH", + reset_anchor="CALENDAR", + unit_singular="call", + unit_plural="calls", + group_slugs=[group], + ), + ) + book.track("entitlements", calls) + + call('client.entitlements.create(..., type="BOOLEAN")') + ensure( + f"entitlement {sso}", + lambda: client.entitlements.create( + name=book.title("Single sign-on"), slug=sso, type="BOOLEAN", description="SSO" + ), + ) + book.track("entitlements", sso) + + call(f'client.entitlements.get("{seats}")') + entitlement = client.entitlements.get(seats) + note( + f"{entitlement.name}: {entitlement.type}, folded with {entitlement.aggregation_method}, " + f"warning at {entitlement.warning_threshold_percent}%" + ) + + call(f'client.entitlement_groups.add_entitlement("{group}", "{sso}")') + client.entitlement_groups.add_entitlement(group, sso) + call(f'client.entitlement_groups.remove_entitlement("{group}", "{sso}")') + client.entitlement_groups.remove_entitlement(group, sso) + done("the group took the entitlement, and gave it back") + + note("update() replaces: everything to keep has to be sent again") + call("client.entitlements.update(slug, ..., display_order=2, warning_threshold_percent=90)") + client.entitlements.update( + seats, + name=book.title("Seats"), + type="NUMBER", + description="Named users", + aggregation_method="SUM", + unit_singular="seat", + unit_plural="seats", + user_facing=True, + display_order=2, + warning_threshold_percent=90, + group_slugs=[group], + ) + done("seats now warn at 90% of the cap") + + +@step("license", "A license, the values it grants, and a second version of it") +def license_step(book: Playbook) -> None: + client = book.client + slug, seats, calls, sso = ( + book.slug("growth"), + book.slug("seats"), + book.slug("api-calls"), + book.slug("sso"), + ) + + call('client.licenses.create(name=..., slug=..., type="PAID", description=...)') + ensure( + f"license {slug}", + lambda: client.licenses.create( + name=book.title("Growth"), + slug=slug, + type="PAID", + description="The plan the playbook meters against", + ), + ) + book.track("licenses", slug) + + note("5 seats with a 20% allowance, so the usage step can reach the refusal") + call("client.licenses.associate_entitlement(license, entitlement, 5, overage_percent=20)") + ensure( + f"grant of {seats}", + lambda: client.licenses.associate_entitlement(slug, seats, 5, overage_percent=20), + ) + call("client.licenses.associate_entitlement(license, entitlement, UNLIMITED)") + ensure( + f"grant of {calls}", lambda: client.licenses.associate_entitlement(slug, calls, UNLIMITED) + ) + call("client.licenses.associate_entitlement(license, entitlement, True)") + ensure(f"grant of {sso}", lambda: client.licenses.associate_entitlement(slug, sso, True)) + + call(f'client.licenses.list_entitlements("{slug}")') + for grant in client.licenses.list_entitlements(slug): + note( + f"{grant.entitlement_slug}: {grant.value.value}" + f" (overage {grant.limit_cap_exceeded_overage_percent}%)" + ) + + call(f'client.licenses.get_entitlement("{slug}", "{seats}")') + granted = client.licenses.get_entitlement(slug, seats) + cap = enforcement.maximum_allowed_usage( + granted.value.value if isinstance(granted.value, Number) else 0, + granted.limit_cap_exceeded_overage_percent, + ) + done(f"the API will accept usage up to {cap}, and refuse anything above") + + license_id = client.licenses.get(slug).id + book.state.set("license_id", license_id) + + versions(book, family=slug) + + +def versions(book: Playbook, *, family: str) -> None: + """A second version of the license: drafted, put on sale, withdrawn and put back. + + The license above opened a family of the same slug, and the family is what a product is: + its slug survives new versions, so it is what flags target and what callers store. The + instance the next steps create stays on version 1 throughout. + """ + client = book.client + draft = f"{family}-v2" # the slug the server would derive; explicit, so a rerun conflicts + + def serving() -> str: + current = client.license_families.get(family).current_version + return f"{current.slug} (version {current.version})" if current else "no version" + + def move(transition: Callable[[str], object], name: str, done_as: str) -> None: + call(f'client.licenses.{name}("{draft}")') + try: + transition(draft) + except ConflictError as error: + note(f"not moved ({error.code or error.status_code}): an earlier run already did") + return + note(f"{done_as}: the family now serves {serving()}") + + note("the license opened a family of the same slug; a new version goes through it") + call(f'client.licenses.create(..., family_slug="{family}", lifecycle_state="DRAFT")') + created = ensure( + f"license version {draft}", + lambda: client.licenses.create( + name=book.title("Growth"), + slug=draft, + type="PAID", + family_slug=family, + lifecycle_state="DRAFT", + description="The next version of the plan, drafted off sale", + ), + ) + book.track("licenses", draft) + if created is not None: + note(f"the server numbered it version {created.version}, {created.lifecycle_state}") + + call(f'client.license_families.get("{family}")') + note(f"the family serves {serving()}: a draft is not on sale") + + move(client.licenses.publish, "publish", "on sale") + move(client.licenses.archive, "archive", "withdrawn") + move(client.licenses.unarchive, "unarchive", "back on sale") + + call(f'client.license_families.get("{family}", version=1, include_versions=True)') + pinned = client.license_families.get(family, version=1, include_versions=True) + history = ", ".join(f"v{v.version} {v.lifecycle_state}" for v in pinned.versions or []) + done(f"version 1 stays addressable by number; the history reads {history or 'empty'}") + + +@step("customer", "A customer, and its link to a CRM") +def customer_step(book: Playbook) -> None: + client = book.client + slug = book.slug("acme") + external = f"crm-{book.run}" + + call("client.customers.create(name=..., slug=..., external_customer_id=..., domain=...)") + ensure( + f"customer {slug}", + lambda: client.customers.create( + name=book.title("Acme"), + slug=slug, + external_customer_id=external, + domain=f"acme-{book.run}.example", + ), + ) + book.track("customers", slug) + + call(f'client.customers.update("{slug}", name=..., external_customer_id=...)') + client.customers.update(slug, name=book.title("Acme Corp"), external_customer_id=external) + + call(f'client.customers.create_integration("{slug}", "{ADAPTER}", external_id=...)') + ensure( + "the CRM link", + lambda: client.customers.create_integration( + slug, ADAPTER, external_id=external, metadata={"stage": "trial"} + ), + ) + call(f'client.customers.get_integration("{slug}", "{ADAPTER}")') + client.customers.get_integration(slug, ADAPTER) + + note("a connector reaches the same customer by its id in the third-party system") + call(f'client.integrations.get_customer("{ADAPTER}", "{external}")') + client.integrations.get_customer(ADAPTER, external) + call(f'client.integrations.upsert_customer("{ADAPTER}", "{external}", name=...)') + client.integrations.upsert_customer( + ADAPTER, external, name=book.title("Acme Corp"), integration_metadata={"stage": "won"} + ) + + customer = client.customers.get(slug) + book.state.set("customer_id", customer.id) + done(f"customer {customer.id}") + + +@step("product", "A component, a release, and the zone it is deployed to") +def product(book: Playbook) -> None: + client = book.client + component_slug, release_slug, zone_slug = ( + book.slug("gateway"), + book.slug("v1"), + book.slug("zone"), + ) + + call('client.components.create(name=..., version="1.0.0", slug=...)') + component = ensure( + f"component {component_slug}", + lambda: client.components.create( + name=book.title("API gateway"), + version="1.0.0", + slug=component_slug, + description="The playbook's only moving part", + ), + ) + book.track("components", component_slug) + component_id = (component or client.components.get(component_slug)).id + + call("client.releases.create(version=..., slug=..., component_ids=[...])") + ensure( + f"release {release_slug}", + lambda: client.releases.create( + version=f"1.0.0-{book.run}", + slug=release_slug, + description="What the zone runs", + component_ids=[component_id], + ), + ) + book.track("releases", release_slug) + release_id = client.releases.get(release_slug).id + + call('client.deployment_zones.create(name=..., type="development", release_id=...)') + ensure( + f"deployment zone {zone_slug}", + lambda: client.deployment_zones.create( + name=book.title("Playground"), + slug=zone_slug, + type="development", + description="Where the playbook's instance runs", + metadata={}, + release_id=release_id, + ), + ) + book.track("deployment_zones", zone_slug) + + note("updating a component replaces it: the version moves, the slug cannot") + call(f'client.components.update("{component_slug}", name=..., version="1.0.1")') + client.components.update( + component_slug, + name=book.title("API gateway"), + version="1.0.1", + description="The playbook's only moving part", + ) + + zone = client.deployment_zones.get(zone_slug) + book.state.set("zone_id", zone.id) + done(f"zone {zone.slug} runs release {zone.release_id}") + + +@step("metadata", "A typed metadata field instances must satisfy") +def metadata(book: Playbook) -> None: + client = book.client + key = book.slug("region") + + call('client.metadata_fields.create(resource_type="INSTANCE", key=..., json_schema=...)') + created = ensure( + f"metadata field {key}", + lambda: client.metadata_fields.create( + resource_type="INSTANCE", + key=key, + label="Playbook region", + json_schema={"type": "string", "enum": ["eu-west-1", "us-east-1"]}, + ), + ) + fields = client.metadata_fields.list("INSTANCE") + field_id = created.id if created else next(f.id for f in fields if f.key == key) + book.state.set("metadata_field", {"id": field_id, "key": key}) + book.track("metadata_fields", field_id) + + note("a schema change can be rehearsed before it refuses anybody's data") + call( + f'client.metadata_fields.dry_run("{field_id}", {{"type": "string", "enum": ["eu-west-1"]}})' + ) + impact = client.metadata_fields.dry_run(field_id, {"type": "string", "enum": ["eu-west-1"]}) + note(f"{impact.count} existing resources would stop satisfying the stricter schema") + + call(f'client.metadata_fields.update("{field_id}", resource_type=..., key=..., label=...)') + client.metadata_fields.update( + field_id, + resource_type="INSTANCE", + key=key, + label="Hosting region", + json_schema={"type": "string", "enum": ["eu-west-1", "us-east-1"]}, + ) + + note("reordering takes every field, so sending the current order changes nothing") + call("client.metadata_fields.reorder([field.id for field in fields])") + client.metadata_fields.reorder([field.id for field in fields]) + done(f"instances may now carry {key}") + + +@step("instance", "An instance of your product, for that customer") +def instance(book: Playbook) -> None: + client = book.client + slug = book.slug("production") + started = datetime.now(timezone.utc) + metadata_field = book.state.get("metadata_field", {}) + instance_metadata = {metadata_field["key"]: "eu-west-1"} if metadata_field else {} + + call("client.instances.create(name=..., customer_id=..., license_id=..., start/end dates)") + ensure( + f"instance {slug}", + lambda: client.instances.create( + name=book.title("Production"), + slug=slug, + customer_id=book.state.get("customer_id"), + license_id=book.state.get("license_id"), + deployment_zone_id=book.state.get("zone_id"), + start_license_date=started, + end_license_date=started + timedelta(days=365), + description="The instance the playbook meters", + metadata=instance_metadata, + ), + ) + book.track("instances", slug) + + call(f'client.instances.get("{slug}")') + created = client.instances.get(slug) + note( + f"{created.name}: customer {created.customer_slug}, license {created.license_slug}, " + f"zone {created.deployment_zone_slug}, status {created.status}" + ) + + call(f'client.instances.update_status("{slug}", "DEGRADED")') + client.instances.update_status(slug, "DEGRADED") + call(f'client.instances.update_lifecycle_stage("{slug}", "TRIAL")') + client.instances.update_lifecycle_stage(slug, "TRIAL") + done("status and lifecycle stage move on their own, without touching the rest") + + call(f'client.instances.create_integration("{slug}", "{ADAPTER}", external_id=...)') + ensure( + "the instance's CRM link", + lambda: client.instances.create_integration( + slug, ADAPTER, external_id=f"inst-{book.run}", metadata={"plan": "growth"} + ), + ) + call(f'client.integrations.get_instance("{ADAPTER}", "inst-{book.run}")') + client.integrations.get_instance(ADAPTER, f"inst-{book.run}") + + +@step("usage", "Meter usage, until the license refuses a report") +def usage(book: Playbook) -> None: + client = book.client + instance_slug, seats, group = book.slug("production"), book.slug("seats"), book.slug("quotas") + + call(f'client.instances.report_usage("{instance_slug}", "{seats}", 1)') + refused = False + for attempt in range(1, 10): + try: + reading = client.instances.report_usage(instance_slug, seats, 1) + except ThresholdExceededError as error: + done(f"report {attempt} was refused: {error.detail or error.code}") + refused = True + break + note(f"report {attempt}: usage is now {amount(reading)}") + if not refused: + warn("the cap was never reached -- is this instance on the playbook's license?") + + call(f'client.instances.get_usage("{instance_slug}", "{seats}")') + reading = client.instances.get_usage(instance_slug, seats) + used = amount(reading) or 0.0 + granted = client.licenses.get_entitlement(book.slug("growth"), seats) + included = granted.value.value if isinstance(granted.value, Number) else 0.0 + overage = granted.limit_cap_exceeded_overage_percent + note(f"{used} of {included} used, {enforcement.percentage_used(included, used)}% of the plan") + note(f"{enforcement.remaining(included, used, overage)} left before reports are refused") + + note("a 'set' report writes the total, which is how to correct it downwards") + call(f'client.instances.report_usage("{instance_slug}", "{seats}", 2, behavior="set")') + corrected = client.instances.report_usage(instance_slug, seats, 2, behavior="set") + done(f"usage is back to {amount(corrected)}") + + call(f'client.instances.list_usage("{instance_slug}")') + for row in client.instances.list_usage(instance_slug): + note(f"{row.entitlement_slug}: {amount(row)}") + + call(f'client.entitlement_groups.get_usage("{group}", "{instance_slug}")') + for member in client.entitlement_groups.get_usage(group, instance_slug): + note(f"{member.entitlement_slug} in the group: {member.entitlement_type}") + + call(f'client.instances.list_audit_trails("{instance_slug}", limit=5)') + for entry in client.instances.list_audit_trails(instance_slug, limit=5): + note(f"{entry.timestamp:%H:%M:%S} {entry.event_name}") + + +def evaluator(book: Playbook) -> OpenFeatureClient | None: + """Register Kaiten as this process's OpenFeature provider, and return a flag client. + + This SDK has no ``evaluate()``, deliberately. Kaiten implements OFREP, so any OpenFeature + SDK reads its flags through the generic provider -- which is the point of the protocol: + the application depends on OpenFeature, not on us. The provider appends ``/ofrep/v1/...`` + to the base URL, so it takes the same URL and the same token as the client above. + """ + try: + from openfeature import api + from openfeature.contrib.provider.ofrep import OFREPProvider + except ImportError: + return None + + token = book.token + api.set_provider( + OFREPProvider( + base_url=book.client.base_url, + headers_factory=lambda: {"Authorization": f"Bearer {token}"} if token else {}, + ) + ) + return api.get_client("playbook") + + +@step("flags", "A feature flag, defined here and evaluated through OpenFeature") +def flags(book: Playbook) -> None: + client = book.client + slug, customer = book.slug("new-checkout"), book.slug("acme") + variants = [targeting.variant("on", True), targeting.variant("off", False)] + rules = [ + targeting.rule("The playbook's customer", f"__kaiten.customer.slug == '{customer}'", "on"), + targeting.split_rule("Everyone else", "true", {"on": 50, "off": 50}), + ] + + call("client.feature_flags.create(name=..., variants=[...], targetings=[...])") + try: + ensure( + f"feature flag {slug}", + lambda: client.feature_flags.create( + name=book.title("New checkout"), + slug=slug, + type="boolean", + variants=variants, + default_variant="off", + targetings=rules, + description="Does this customer get the new checkout?", + metadata={"playbook": book.run}, + ), + ) + except APIStatusError as error: + warn(f"the targeting rule was refused ({error.code or error.status_code}); using 'true'") + ensure( + f"feature flag {slug}", + lambda: client.feature_flags.create( + name=book.title("New checkout"), + slug=slug, + type="boolean", + variants=variants, + default_variant="off", + targetings=[targeting.rule("Everyone", "true", "on")], + ), + ) + book.track("feature_flags", slug) + + call("client.feature_flags.manifest()") + note(f"{len(client.feature_flags.manifest())} flags in the OpenFeature manifest") + + note("the SDK defines flags; it does not evaluate them -- OpenFeature does, over OFREP") + call("api.set_provider(OFREPProvider(base_url=..., headers_factory=...))") + flags_client = evaluator(book) + if flags_client is None: + warn("skipped: pip install openfeature-sdk openfeature-provider-ofrep to evaluate") + return + + from openfeature.evaluation_context import EvaluationContext + + context = EvaluationContext(targeting_key=customer, attributes={"region": "eu"}) + call(f'flags.get_boolean_value("{slug}", False, EvaluationContext(targeting_key=...))') + note( + f"the new checkout is {'on' if flags_client.get_boolean_value(slug, False, context) else 'off'} for {customer}" + ) + + call(f'flags.get_boolean_details("{slug}", False, context)') + details = flags_client.get_boolean_details(slug, False, context) + note(f"value {details.value}, variant {details.variant}, reason {details.reason}") + note("a failed evaluation returns the default you passed: that is OpenFeature's contract") + + note("disabling a flag is a hard kill switch: no rule is even evaluated") + call(f'client.feature_flags.update("{slug}", ..., enabled=False)') + client.feature_flags.update( + slug, + name=book.title("New checkout"), + type="boolean", + variants=variants, + default_variant="off", + targetings=rules, + enabled=False, + ) + after = flags_client.get_boolean_details(slug, False, context) + done(f"now it answers {after.value} with reason {after.reason}") + + +@step("tokens", "A service account, and a token that works until it is revoked") +def tokens(book: Playbook) -> None: + client = book.client + account, token_slug = book.slug("robot"), book.slug("token") + + call("client.service_accounts.create(name=..., slug=...)") + ensure( + f"service account {account}", + lambda: client.service_accounts.create(name=book.title("Robot"), slug=account), + ) + book.track("service_accounts", account) + + call('client.service_accounts.create_token(account, name=..., scopes=["read:customers"])') + minted = client.service_accounts.create_token( + account, + name=book.title("playbook token"), + slug=token_slug, + scopes=["read:customers"], + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + book.track("tokens", f"{account}/{token_slug}") + secret = required(minted.token, "the token's plaintext") + note(f"the plaintext is returned once, here and nowhere else: {secret[:8]}...") + + probe = book.new_client(secret) + try: + note(f"the minted token reads {len(probe.customers.list())} customers") + except APIStatusError as error: + warn(f"the minted token could not read customers: {error}") + finally: + probe.close() + + call(f'client.service_accounts.list_tokens("{account}")') + note(f"{len(client.service_accounts.list_tokens(account))} live tokens on the account") + + call(f'client.service_accounts.delete_token("{account}", "{token_slug}")') + client.service_accounts.delete_token(account, token_slug) + revoked = book.new_client(secret) + try: + revoked.customers.list() + warn("the revoked token still answers -- that is worth reporting") + except AuthenticationError: + done("the revoked token is refused immediately, on every replica") + except APIStatusError as error: + note(f"the revoked token now fails with {error.status_code}") + finally: + revoked.close() + + +@step("webhooks", "Verify a delivery, with nothing on the network") +def webhooks(book: Playbook) -> None: + secret = "whsec_" + base64.b64encode(b"playbook-signing-secret-0123456").decode() + payload = json.dumps( + { + "name": "INSTANCE_ENTITLEMENT_CAP_EXCEEDED", + "type": "com.kaiten.instance.entitlement.v1.cap_exceeded", + "data": { + "entitlement_slug": book.slug("seats"), + "threshold": 5, + "value": 6, + "overage": 1, + }, + } + ).encode() + headers = sign(payload, secret) + + note("this is the delivery Kaiten would send when usage crosses the included value") + call("kaitencloud.webhooks.verify_webhook(payload, headers, secret=...)") + event = verify_webhook(payload, headers, secret=secret) + if isinstance(event, InstanceEntitlementCapExceededEvent): + done(f"{event.name}: {event.data.entitlement_slug} is {event.data.overage} over its cap") + + call("verify_webhook(tampered_payload, headers, secret=...)") + try: + verify_webhook(payload.replace(b'"overage": 1', b'"overage": 9'), headers, secret=secret) + warn("a tampered payload verified -- that should never happen") + except WebhookVerificationError as error: + done(f"a tampered payload is refused: {error}") + + +@step("platform", "The Platform API, if a ksm_ credential is configured") +def platform(book: Playbook) -> None: + if book.platform is None: + note("skipped: set KAITEN_PLATFORM_BASE_URL and KAITEN_PLATFORM_TOKEN to include it") + return + + call("platform.me()") + credential = book.platform.me() + note(f"{credential.name} ({credential.credential_kind}), subject {credential.subject}") + note(f"scopes: {', '.join(credential.scopes or []) or 'none'}") + + if not book.platform_org: + note("pass --platform-org to also read an organization and mint a token in it") + return + + org = book.platform_org + call(f'platform.organizations.get("{org}")') + note(f"organization {book.platform.organizations.get(org).name}") + + note("a platform credential never acts inside an organization: it mints a token that does") + call("platform.tokens.mint(org, name=..., scopes=[...], ttl=timedelta(minutes=15))") + minted = book.platform.tokens.mint( + org, + name=book.title("platform token"), + scopes=["read:customers"], + ttl=timedelta(minutes=15), + ) + slug = required(minted.slug, "the minted token's slug") + note(f"minted {slug}; its plaintext exists only in this response") + + call(f'platform.tokens.list("{org}")') + note(f"{len(book.platform.tokens.list(org))} tokens this credential holds in that organization") + + call(f'platform.tokens.revoke("{org}", "{slug}")') + book.platform.tokens.revoke(org, slug) + done("minted, listed and revoked -- revoking the parent would have taken it anyway") + + +@step("cleanup", "Delete everything this run created") +def cleanup(book: Playbook) -> None: + client = book.client + + # In dependency order: an instance holds a license, a zone holds a release. + deletions: list[tuple[str, str, Callable[[str], object]]] = [ + ("feature_flags", "feature flag", client.feature_flags.delete), + ("instances", "instance", client.instances.delete), + ("licenses", "license", client.licenses.delete), + ("customers", "customer", client.customers.delete), + ("entitlements", "entitlement", client.entitlements.delete), + ("entitlement_groups", "entitlement group", client.entitlement_groups.delete), + ("deployment_zones", "deployment zone", client.deployment_zones.delete), + ("releases", "release", client.releases.delete), + ("components", "component", client.components.delete), + ] + for kind, label, delete in deletions: + for identifier in book.created(kind): + forget(f"{label} {identifier}", partial(delete, identifier)) + + for field_id in book.created("metadata_fields"): + note("metadata fields are archived, never deleted: archiving frees the key") + forget(f"metadata field {field_id}", partial(client.metadata_fields.archive, field_id)) + + if book.created("service_accounts"): + warn( + "service accounts have no delete endpoint: " + f"{', '.join(book.created('service_accounts'))} stays, with no live token" + ) + + book.state.clear() + done("the state file is gone; the next run starts fresh") + + +def forget(what: str, delete: Callable[[], object]) -> None: + try: + delete() + except NotFoundError: + note(f"{what} was already gone") + except APIStatusError as error: + warn(f"could not delete {what}: {error}") + else: + done(f"deleted {what}") + + +def sign(payload: bytes, secret: str) -> dict[str, str]: + """The headers Kaiten's webhook delivery carries, so the step can verify a real one.""" + timestamp = str(int(time.time())) + key = base64.b64decode(secret.removeprefix("whsec_")) + digest = hmac.new(key, f"msg_playbook.{timestamp}.".encode() + payload, hashlib.sha256) + return { + "svix-id": "msg_playbook", + "svix-timestamp": timestamp, + "svix-signature": "v1," + base64.b64encode(digest.digest()).decode(), + } + + +# -------------------------------------------------------------------------------------------- +# Running them +# -------------------------------------------------------------------------------------------- + + +def parse(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="playbook.py", + description="A scripted walk through a Kaiten deployment.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="steps: " + ", ".join(step.name for step in STEPS), + ) + parser.add_argument("steps", nargs="*", help="the steps to run (default: all but cleanup)") + parser.add_argument("--list", action="store_true", help="list the steps and exit") + parser.add_argument("--from", dest="start", metavar="STEP", help="resume at this step") + parser.add_argument("--pause", action="store_true", help="wait for Enter before each step") + parser.add_argument("--base-url", help="the API, e.g. http://localhost:6000") + parser.add_argument("--token", help="an organization token (ksh_...)") + parser.add_argument("--platform-org", metavar="UUID", help="mint a token in this organization") + parser.add_argument("--state", type=Path, default=STATE_FILE, help="where the run is recorded") + parser.add_argument("--verbose", action="store_true", help="log retries and fallbacks") + return parser.parse_args(argv) + + +def chosen(args: argparse.Namespace) -> list[Step]: + names = [step.name for step in STEPS] + for name in args.steps: + if name not in names: + raise SystemExit(f"unknown step {name!r}; try --list") + if args.steps: + return [step for step in STEPS if step.name in args.steps] + + running = [step for step in STEPS if step.name != "cleanup"] + if args.start: + if args.start not in names: + raise SystemExit(f"unknown step {args.start!r}; try --list") + started = [step.name for step in running].index(args.start) + return running[started:] + return running + + +def build(args: argparse.Namespace) -> Playbook: + client = KaitenClient(base_url=args.base_url, token=args.token) + platform_url = os.environ.get("KAITEN_PLATFORM_BASE_URL") + platform_token = os.environ.get("KAITEN_PLATFORM_TOKEN") + platform_client = ( + KaitenPlatformClient(base_url=platform_url, token=platform_token) + if platform_url and platform_token + else None + ) + return Playbook( + client=client, + state=State(args.state), + platform=platform_client, + platform_org=args.platform_org, + token=args.token or os.environ.get("KAITEN_AUTH_TOKEN"), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse(argv) + if args.list: + for number, step in enumerate(STEPS, 1): + print(f"{number:>3}. {paint(step.name, '1')} {step.title}") + return 0 + if args.verbose: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + steps = chosen(args) + try: + book = build(args) + except (CredentialError, ValueError) as error: # no address, no token, or a malformed one + raise SystemExit(f"{error}\nSee examples/README.md, Before you start.") from None + heading(f"Kaiten playbook {book.run} against {book.client.base_url}") + + for number, step in enumerate(steps, 1): + heading(f"{number}/{len(steps)} {step.name} -- {step.title}") + if args.pause: + input(paint(" Enter to run this step, Ctrl-C to stop ", "2")) + try: + step.run(book) + except KeyboardInterrupt: + print() + warn(f"stopped; resume with --from {step.name}") + return 130 + except (APIStatusError, PlaybookError) as error: + report(error) + warn(f"resume with --from {step.name} once it is sorted out") + return 1 + + heading("Done") + note(f"what this run created is listed in {args.state}") + if any(step.name == "cleanup" for step in steps): + return 0 + note(f"delete it all with: python {sys.argv[0]} cleanup") + return 0 + + +def report(error: Exception) -> None: + if isinstance(error, APIStatusError): + warn(f"{error}") + if error.error_id: + note(f"quote errorId {error.error_id} in a bug report") + if isinstance(error, PermissionDeniedError): + note("this token is missing a scope for that operation") + return + warn(str(error)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openapi/coverage.yaml b/openapi/coverage.yaml new file mode 100644 index 0000000..7a099de --- /dev/null +++ b/openapi/coverage.yaml @@ -0,0 +1,181 @@ +# Every operation in the two contract snapshots, mapped to the SDK method(s) that call it. +# +# tests/test_coverage.py reads this file and fails when: +# - an operation in openapi.yaml or platform-openapi.yaml is neither mapped nor excluded, +# - a mapped method is missing from the sync or the async client, +# - a client exposes a public method that nothing here maps. +# +# So a contract sync that adds an endpoint cannot ship silently unwrapped, and a wrapper +# cannot exist without naming the operation it calls. scripts/generate_models.py reads +# the exclusions too: a model only reachable from an excluded operation is not generated. + +core: + operations: + # components + list-components: components.list + get-component: components.get + create-component: components.create + update-component: components.update + delete-component: components.delete + + # connectors + list-connectors: connectors.list + get-connector: connectors.get + get-connector-state: connectors.get_state + activate-connector: connectors.activate + deactivate-connector: connectors.deactivate + get-connector-settings-schema: connectors.get_settings_schema + get-connector-settings: connectors.get_settings + update-connector-settings: connectors.update_settings + delete-connector-settings: connectors.delete_settings + + # customers + list-customers: customers.list + get-customer: customers.get + create-customer: customers.create + update-customer: customers.update + delete-customer: customers.delete + get-customer-integration: customers.get_integration + create-customer-integration: customers.create_integration + update-customer-integration: customers.update_integration + delete-customer-integration: customers.delete_integration + + # deployment zones + list-deployment-zones: deployment_zones.list + get-deployment-zone-by-slug: deployment_zones.get + create-deployment-zone: deployment_zones.create + update-deploymentZone: deployment_zones.update + delete-deployment-zone: deployment_zones.delete + + # entitlement groups + list-entitlement-groups: entitlement_groups.list + get-entitlement-group: entitlement_groups.get + create-entitlement-group: entitlement_groups.create + update-entitlement-group: entitlement_groups.update + delete-entitlement-group: entitlement_groups.delete + add-entitlement-to-group: entitlement_groups.add_entitlement + remove-entitlement-from-group: entitlement_groups.remove_entitlement + get-entitlement-group-usage: entitlement_groups.get_usage + + # entitlements + list-entitlements: entitlements.list + get-entitlement: entitlements.get + create-entitlement: entitlements.create + update-entitlement: entitlements.update + delete-entitlement: entitlements.delete + + # feature flags + get-feature-flags: feature_flags.list + get-feature-flag: feature_flags.get + create-feature-flag: feature_flags.create + update-feature-flag: feature_flags.update + delete-feature-flag: feature_flags.delete + get-openfeature-manifest: feature_flags.manifest + + # instances + getInstances: instances.list + getInstance: instances.get + createInstance: instances.create + updateInstance: instances.update + patchInstance: [instances.update_status, instances.update_lifecycle_stage] + deleteInstance: instances.delete + getAuditTrails: instances.list_audit_trails + getEntitlementsUsageMetrics: instances.list_usage + getEntitlementUsageMetrics: instances.get_usage + reportEntitlementUsageMetric: instances.report_usage + get-instance-integration: instances.get_integration + create-instance-integration: instances.create_integration + update-instance-integration: instances.update_integration + delete-instance-integration: instances.delete_integration + + # integrations, addressed by the third-party system's identifier + get-customer-integration-by-external-id: integrations.get_customer + upsert-customer-integration-by-external-id: integrations.upsert_customer + get-instance-integration-by-external-id: integrations.get_instance + upsert-instance-integration-by-external-id: integrations.upsert_instance + + # licenses + get-licenses: licenses.list + get-license: licenses.get + create-license: licenses.create + update-license: licenses.update + delete-license: licenses.delete + get-license-entitlements: licenses.list_entitlements + get-license-entitlement: licenses.get_entitlement + associate-entitlement-with-license: licenses.associate_entitlement + update-license-entitlement: licenses.update_entitlement + delete-license-entitlement: licenses.delete_entitlement + publish-license: licenses.publish + archive-license: licenses.archive + unarchive-license: licenses.unarchive + + # license families: the products whose versions the licenses above are + list-license-families: license_families.list + get-license-family: license_families.get + + # metadata fields + list-metadata-fields: metadata_fields.list + create-metadata-field: metadata_fields.create + update-metadata-field: metadata_fields.update + archive-metadata-field: metadata_fields.archive + unarchive-metadata-field: metadata_fields.unarchive + dry-run-metadata-field: metadata_fields.dry_run + reorder-metadata-fields: metadata_fields.reorder + + # releases + list-releases: releases.list + get-release-by-slug: releases.get + create-release: releases.create + delete-release: releases.delete + + # service accounts + get-service-accounts: service_accounts.list + get-service-account: service_accounts.get + create-service-account: service_accounts.create + update-service-account: service_accounts.update + get-service-account-tokens: service_accounts.list_tokens + create-service-account-token: service_accounts.create_token + delete-service-account-token: service_accounts.delete_token + + excluded: + get-targeting-context: >- + Rule-editor tooling for the Kaiten console, with no SDK audience. kaiten's own + operation-coverage test excludes the same three operations for the same reason. + lint-targeting-rule: Rule-editor tooling for the Kaiten console, with no SDK audience. + test-targeting-rule: Rule-editor tooling for the Kaiten console, with no SDK audience. + + listNotifications: >- + A person's own notification feed. It addresses the signed-in user's rows -- an unread + count and a set of choices about what to be told -- and a machine client holding an API + token has neither; wrapping it would mean pretending to be a particular human. kaiten's + own operation-coverage test exempts these four operations for the same reason. + markNotificationsRead: A person's own read state; an API token has none. + getNotificationPreferences: A person's own notification choices; an API token has none. + putNotificationPreferences: A person's own notification choices; an API token has none. + + evaluateFlag: >- + OFREP, which is a protocol before it is an endpoint. Kaiten implements it so that any + OpenFeature SDK can evaluate its flags through the generic OFREP provider -- in Python, + `openfeature-provider-ofrep`. That provider already has the hooks, the evaluation + context, the typed accessors and the default-on-failure semantics OpenFeature defines, + so a method here would be a second and worse client for the same endpoint, and would + tie flag evaluation to this SDK. The README and examples/playbook.py show the provider. + evaluateFlagsBulk: >- + OFREP, like evaluateFlag: the provider calls it when an OpenFeature client evaluates + every flag at once. (sdk-js does keep a bulk call, but only to feed the licensing + snapshot its React components render; nothing in a Python SDK needs that.) + +platform: + operations: + get-platform-me: me + register-connector: connectors.register + ensure-organization: organizations.ensure + get-organization: organizations.get + delete-organization: organizations.delete + delete-membership: organizations.delete_membership + mint-organization-token: tokens.mint + list-organization-tokens: tokens.list + revoke-organization-token: tokens.revoke + delete-user: users.delete + + excluded: {} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..e09e19c --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,12325 @@ +components: + schemas: + AuditTrail: + additionalProperties: false + properties: + eventName: + description: Event name (e.g. ENTITLEMENT_VALUE_GET) + type: string + eventType: + description: Event type identifier (e.g. com.kaiten.instance.entitlement.v1.value_get) + type: string + id: + description: Unique audit trail entry ID + type: string + instanceId: + description: Instance UUID (present for instance-scoped events) + type: string + instanceSlug: + description: Instance slug + type: string + payload: + description: Event payload (JSON) + timestamp: + description: When the event occurred + format: date-time + type: string + required: + - id + - instanceSlug + - eventName + - eventType + - timestamp + type: object + Basic: + additionalProperties: false + properties: + type: + enum: + - basic + type: string + value: + type: string + required: + - type + - value + type: object + BasicTargeting: + additionalProperties: false + properties: + name: + examples: + - Targeting Rule 1 + type: string + rule: + description: CEL expression for basicTargeting evaluation + examples: + - user.group == 'beta' + maxLength: 10000 + type: string + type: + enum: + - basic + type: string + variant: + type: string + required: + - type + - name + - variant + - rule + type: object + BooleanEntitlementValue: + additionalProperties: false + properties: + type: + description: Value type discriminator + enum: + - boolean + examples: + - boolean + type: string + value: + description: Boolean value + examples: + - true + type: boolean + required: + - type + - value + type: object + BulkEvaluationFailure: + additionalProperties: false + properties: + errorCode: + description: Error code specific to the bulk evaluation error + type: string + errorDetails: + description: Optional error details description + type: string + required: + - errorCode + type: object + BulkEvaluationRequest: + additionalProperties: false + properties: + context: + additionalProperties: {} + type: object + type: object + Command: + additionalProperties: false + properties: + entitlementSlug: + type: string + required: + - entitlementSlug + type: object + Component: + additionalProperties: false + properties: + createdAt: + description: Timestamp when the component was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this component + readOnly: true + description: + description: Description of the component + examples: + - Main API gateway component + maxLength: 500 + type: string + id: + description: Unique identifier for the component + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + name: + description: Name of the component + examples: + - api-gateway + maxLength: 100 + minLength: 1 + type: string + previousComponentId: + description: Reference to the previous version of this component + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - api-gateway-v1-2-3 + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + version: + description: Version of the component + examples: + - v1.2.3 + maxLength: 100 + minLength: 1 + type: string + required: + - id + - name + - version + - createdBy + - createdAt + type: object + ConfigEntitlementValue: + additionalProperties: false + properties: + type: + description: Value type discriminator + enum: + - object + examples: + - object + type: string + value: + additionalProperties: {} + description: Object configuration value + type: object + required: + - type + - value + type: object + Connector: + additionalProperties: false + properties: + created_at: + description: Timestamp when connector was first registered + format: date-time + type: string + entitlement_slug: + description: Slug of the BOOLEAN entitlement a license must grant for an organization to activate this connector. Absent means the connector is ungated. + examples: + - connector-attio + type: string + name: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + type: string + settings_schema: + additionalProperties: {} + description: JSON schema used to validate connector settings payloads + type: object + updated_at: + description: Timestamp when connector registration was last updated + format: date-time + type: string + version: + description: Registered connector version + examples: + - 1.0.0 + type: string + required: + - name + - version + - settings_schema + - created_at + - updated_at + type: object + ConnectorActivation: + additionalProperties: false + properties: + activatedAt: + description: When this organization first activated the connector + format: date-time + type: string + connectorName: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + type: string + required: + - connectorName + - activatedAt + type: object + ConnectorSettings: + additionalProperties: false + properties: + connector_name: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + readOnly: true + type: string + settings: + additionalProperties: {} + description: Connector settings payload + type: object + required: + - connector_name + - settings + type: object + ConnectorSettingsSchema: + additionalProperties: false + properties: + connector_name: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + type: string + schema: + additionalProperties: {} + description: JSON schema describing supported connector settings payload + type: object + version: + description: Registered connector version + examples: + - 1.0.0 + type: string + required: + - connector_name + - version + - schema + type: object + ConnectorState: + additionalProperties: false + properties: + activated: + description: Whether this organization has turned the connector on + type: boolean + activatedAt: + description: When this organization activated the connector. Absent when it has not. + format: date-time + type: string + available: + description: Whether this deployment has the connector registered + type: boolean + connectorName: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + type: string + entitled: + description: Whether this organization's licence includes the connector. True for an ungated connector. + type: boolean + version: + description: Registered connector version. Absent when the connector is not available. + examples: + - 1.0.0 + type: string + required: + - connectorName + - available + - entitled + - activated + type: object + Customer: + additionalProperties: false + properties: + createdAt: + description: Timestamp when the customer was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this customer + readOnly: true + domain: + description: Optional customer domain name + examples: + - example.tld + pattern: ^[a-z0-9_-]+(\.[a-z0-9_-]+)+$ + type: string + externalCustomerId: + description: Optional identifier for the customer in an external system + examples: + - external-customer-id-12345 + type: + - string + - "null" + id: + description: Unique identifier for the customer + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + integrations: + additionalProperties: + $ref: "#/components/schemas/CustomerIntegration" + description: Integrations grouped by adapter name + type: object + name: + description: Name of the customer + examples: + - Awesome Customer + minLength: 1 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - awesome-customer + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + updatedAt: + description: Timestamp when the customer was last updated + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + updatedBy: + $ref: "#/components/schemas/User" + description: User who last updated this customer + readOnly: true + required: + - id + - name + - createdBy + - createdAt + - updatedBy + - updatedAt + type: object + CustomerBody: + additionalProperties: false + properties: + domain: + description: Optional customer domain name + examples: + - example.tld + pattern: ^[a-z0-9_-]+(\.[a-z0-9_-]+)+$ + type: string + error: + description: Optional integration error message + type: string + integrationMetadata: + additionalProperties: {} + description: Adapter-specific integration metadata + type: object + name: + description: Customer name + examples: + - Awesome Customer + minLength: 1 + type: string + slug: + description: Customer slug + examples: + - awesome-customer + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + webUrl: + description: Absolute http(s) link to the record in the third-party system + examples: + - https://app.attio.com/w/acme/record/rec_12345 + type: string + type: object + CustomerCreationRejected: + additionalProperties: false + properties: + name: + type: string + reason: + type: string + slug: + type: string + required: + - name + - slug + - reason + type: object + CustomerIntegration: + additionalProperties: false + properties: + external_id: + description: External identifier in the third-party adapter + examples: + - rec_12345 + type: string + last_error: + description: Last synchronization error message, if any + type: string + metadata: + additionalProperties: {} + description: Adapter-specific integration metadata + type: object + synced_at: + description: Timestamp of the last synchronization with the adapter + format: date-time + readOnly: true + type: string + web_url: + description: Absolute http(s) link to the record in the third-party system + examples: + - https://app.attio.com/w/acme/company/rec_12345 + type: string + required: + - external_id + - synced_at + type: object + CustomerIntegrationResource: + additionalProperties: false + properties: + domain: + description: Optional customer domain name + examples: + - example.tld + type: string + error: + description: Last synchronization error for this integration, if any + type: string + integrationMetadata: + additionalProperties: {} + description: Adapter-specific customer integration metadata + type: object + name: + description: Customer name + examples: + - Awesome Customer + type: string + slug: + description: Customer slug + examples: + - awesome-customer + type: string + webUrl: + description: Absolute http(s) link to the record in the third-party system + examples: + - https://app.attio.com/w/acme/company/rec_12345 + type: string + required: + - integrationMetadata + - name + - slug + type: object + DefaultVariant: + discriminator: + mapping: + basic: "#/components/schemas/Basic" + rollout_date: "#/components/schemas/RolloutDate" + rollout_percentage: "#/components/schemas/RolloutPercentage" + propertyName: type + oneOf: + - $ref: "#/components/schemas/Basic" + - $ref: "#/components/schemas/RolloutDate" + - $ref: "#/components/schemas/RolloutPercentage" + Deployment: + additionalProperties: false + properties: + createdAt: + description: Timestamp when the deployment was recorded + examples: + - "2023-10-01T12:00:00Z" + format: date-time + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who recorded this deployment + deploymentZoneId: + description: Deployment zone the release was deployed to + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + id: + description: Unique identifier for this deployment event + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + releaseId: + description: Release that was deployed + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + required: + - id + - deploymentZoneId + - releaseId + - createdBy + - createdAt + type: object + DeploymentZone: + additionalProperties: false + properties: + createdAt: + description: Timestamp when the deployment zone was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this deployment zone + readOnly: true + description: + description: Description of the deployment zone + examples: + - "2023-10-01T12:00:00Z" + type: string + id: + description: Unique identifier for the deployment zone + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + metadata: + additionalProperties: {} + description: Typed metadata for the deployment zone, validated against the org-scoped MetadataField schema + examples: + - region: eu-west-1 + type: object + name: + description: Name of the deployment zone + examples: + - AWS us-west-2 + minLength: 1 + type: string + releaseId: + description: Currently deployed release ID + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - aws-us-west-2 + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + type: + description: "Environment class of the deployment zone. Free-form: any value is stored as sent, but production, staging and development are the ones the console labels, and feature-flag targeting rules match on this field as __kaiten.deploymentZone.type." + examples: + - production + type: string + updatedAt: + description: Timestamp when the deployment zone was last updated + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + updatedBy: + $ref: "#/components/schemas/User" + description: User who last updated the deployment zone + readOnly: true + required: + - id + - name + - type + - description + - createdBy + - createdAt + - updatedBy + - updatedAt + type: object + Entitlement: + additionalProperties: false + properties: + aggregationMethod: + description: Method used to fold each reported usage value into the stored total (NUMBER only). Defaults to SUM. + enum: + - COUNT + - SUM + - AVERAGE + - MIN + - MAX + - LATEST + examples: + - SUM + type: string + createdAt: + description: Timestamp when this entitlement was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + description: + description: Description of the entitlement + examples: + - Basic subscription plan with limited features + type: + - string + - "null" + displayOrder: + description: Sort order of this entitlement in customer-facing components (ascending). Defaults to 0. + examples: + - 10 + format: int32 + minimum: 0 + type: integer + entitlementGroups: + description: Groups associated with this entitlement + items: + $ref: "#/components/schemas/EntitlementGroupSummary" + readOnly: true + type: + - array + - "null" + groupSlugs: + description: Groups associated with this entitlement, identified by slug + items: + type: string + type: + - array + - "null" + writeOnly: true + icon: + description: Optional icon, stored as a provider-namespaced token (provider:name). Decoupled from any specific icon library. + examples: + - lucide:rocket + maxLength: 64 + pattern: ^[a-z0-9]+:[a-z0-9]+(-[a-z0-9]+)*$ + type: string + id: + description: Unique identifier for the entitlement + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + name: + description: Name of the entitlement + examples: + - Basic Plan + minLength: 1 + type: string + resetAnchor: + description: "Phase of the periodic usage window: CALENDAR (UTC calendar-aligned) or LICENSE_START (phased off the instance's license start date). Required exactly when resetPeriod is set; defaults to CALENDAR when omitted. Immutable once set." + enum: + - CALENDAR + - LICENSE_START + examples: + - CALENDAR + type: string + resetPeriod: + description: Cadence at which usage automatically resets for this NUMBER entitlement. NULL means a lifetime counter (default). Immutable once set -- a full-replace PUT must echo back the stored value. + enum: + - HOUR + - DAY + - WEEK + - MONTH + - YEAR + examples: + - MONTH + type: string + saleUnitFactor: + description: Number of base units one sale unit represents (1 sale unit = N base units). + examples: + - 3 + exclusiveMinimum: 0 + format: double + type: number + saleUnitPlural: + description: Plural label of the unit this entitlement is sold in, when it differs from the base unit. + examples: + - packs + maxLength: 100 + minLength: 1 + type: string + saleUnitSingular: + description: Singular label of the unit this entitlement is sold in, when it differs from the base unit. Requires the base unit labels and the full sale unit trio (saleUnitSingular, saleUnitPlural, saleUnitFactor). + examples: + - pack + maxLength: 100 + minLength: 1 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - basic-plan + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + type: + description: Type of the entitlement + enum: + - BOOLEAN + - NUMBER + - CONFIG + - NUMBER_AI_CREDIT + examples: + - NUMBER + type: string + unitPlural: + description: Plural label of the base unit this entitlement is measured in (NUMBER type only). Must be provided together with unitSingular. + examples: + - seats + maxLength: 100 + minLength: 1 + type: string + unitSingular: + description: Singular label of the base unit this entitlement is measured in (NUMBER type only). Must be provided together with unitPlural. + examples: + - seat + maxLength: 100 + minLength: 1 + type: string + updatedAt: + description: Timestamp when this entitlement was last updated + examples: + - "2023-10-02T12:00:00Z" + format: date-time + readOnly: true + type: string + userFacing: + description: Whether this entitlement is displayed in customer-facing components (e.g. plan and pricing pages). Defaults to false. + examples: + - true + type: boolean + warningThresholdPercent: + description: Percentage of the cap at which an early-warning event fires before the cap is reached. 0 disables the warning (default). + examples: + - 80 + format: int32 + maximum: 100 + minimum: 0 + type: integer + required: + - id + - name + - description + - createdAt + - updatedAt + type: object + EntitlementGroup: + additionalProperties: false + properties: + description: + description: Description of the entitlement group + examples: + - All entitlements related to AI usage quotas + type: + - string + - "null" + id: + description: Unique identifier for the entitlement group + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + name: + description: Name of the entitlement group + examples: + - AI Quotas + minLength: 1 + type: string + slug: + description: URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - ai-quotas + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + required: + - id + - name + - description + type: object + EntitlementGroupSummary: + additionalProperties: false + properties: + id: + description: Unique identifier for the entitlement group + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + name: + description: Name of the entitlement group + examples: + - AI Quotas + type: string + slug: + description: URL-friendly identifier for the entitlement group + examples: + - ai-quotas + type: string + required: + - id + - name + - slug + type: object + EntitlementGroupUsage: + additionalProperties: false + properties: + currentPeriodEnd: + description: End of the current usage window (exclusive). Null for a lifetime entitlement. + format: date-time + type: string + currentPeriodStart: + description: Start of the current usage window (inclusive). Null for a lifetime entitlement. + format: date-time + type: string + entitlementId: + description: Entitlement ID + type: string + entitlementName: + description: Entitlement name + type: string + entitlementSlug: + description: Entitlement slug + type: string + entitlementType: + description: Entitlement type + type: string + licenseValue: + description: License limit value, discriminated by the 'type' field. Null when the license grants no value. + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + usageValue: + description: Current usage value, discriminated by the 'type' field. Null when nothing has been reported yet. + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + required: + - entitlementId + - entitlementSlug + - entitlementName + - entitlementType + type: object + EntitlementUsage: + additionalProperties: false + properties: + currentPeriodEnd: + description: End of the current usage window (exclusive). Null for a lifetime entitlement (no configured reset period). + examples: + - "2026-04-01T00:00:00Z" + format: date-time + type: string + currentPeriodStart: + description: Start of the current usage window (inclusive). Null for a lifetime entitlement (no configured reset period). + examples: + - "2026-03-01T00:00:00Z" + format: date-time + type: string + entitlementId: + description: Unique identifier for the entitlement + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + entitlementSlug: + description: URL-friendly identifier of the entitlement + examples: + - premium-support + type: string + licenseId: + description: Unique identifier for the license to which this entitlement usage belongs + examples: + - 123e4567-e89b-12d3-a456-426614174001 + type: string + licenseSlug: + description: URL-friendly identifier of the license + examples: + - enterprise-license + type: string + limit: + description: License grant this usage is measured against, discriminated by the 'type' field. Null when the license grants no value; -1 means unlimited for a NUMBER entitlement. + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + value: + description: Resolved entitlement value, discriminated by the 'type' field + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + required: + - entitlementId + - entitlementSlug + - licenseId + - licenseSlug + - value + type: object + ErrorDetail: + additionalProperties: false + properties: + location: + description: Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + type: string + message: + description: Error message text + type: string + value: + description: The value at the given location + type: object + EvaluationFailure: + additionalProperties: false + properties: + errorCode: + description: OpenFeature compatible error code + type: string + errorDetails: + description: Error description + type: string + key: + description: Feature flag key + type: string + metadata: + additionalProperties: {} + description: Arbitrary metadata for the flag + type: object + required: + - key + - errorCode + type: object + EvaluationRequest: + additionalProperties: false + properties: + context: + additionalProperties: {} + type: object + type: object + EvaluationSuccess: + additionalProperties: false + properties: + key: + description: Feature flag key + type: string + metadata: + additionalProperties: {} + description: Arbitrary metadata for the flag + type: object + reason: + description: OpenFeature reason for the evaluation + examples: + - STATIC + type: string + value: + description: Flag evaluation result + variant: + description: string of the evaluated flag value + type: string + required: + - key + type: object + FeatureFlag: + additionalProperties: false + properties: + default_variant: + $ref: "#/components/schemas/DefaultVariant" + description: + examples: + - This is a new feature flag + type: + - string + - "null" + enabled: + examples: + - true + type: boolean + event_name: + examples: + - feature_flag_event + type: string + id: + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + metadata: + additionalProperties: {} + type: object + name: + examples: + - New Feature + minLength: 1 + type: string + slug: + examples: + - new-feature + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + targetings: + $ref: "#/components/schemas/Targetings" + type: + enum: + - boolean + - string + - number + - object + examples: + - boolean + type: string + variants: + items: + $ref: "#/components/schemas/Variant" + type: + - array + - "null" + required: + - id + - type + - variants + - targetings + - name + - description + - default_variant + - metadata + - enabled + - event_name + type: object + FeatureFlagEvaluated: + additionalProperties: false + properties: + error_code: + description: Set when the evaluation failed and the default was returned + type: string + error_message: + description: Human-readable detail accompanying error_code + type: string + flag_id: + description: Unique identifier of the evaluated flag + type: string + flag_slug: + description: URL-friendly identifier of the evaluated flag + type: string + matched_rule_name: + description: Name of the targeting rule that matched, when the reason is TARGETING_MATCH + type: string + reason: + description: Why the value resolved as it did + type: string + variant: + description: Name of the resolved variant, when the flag has one + type: string + required: + - flag_slug + - flag_id + - reason + type: object + Impact: + additionalProperties: false + properties: + count: + description: Number of resources whose stored value for this field would no longer satisfy the candidate schema + format: int64 + type: integer + samples: + description: Up to 5 impacted resources, for display + items: + $ref: "#/components/schemas/ImpactSample" + type: + - array + - "null" + required: + - count + - samples + type: object + ImpactSample: + additionalProperties: false + properties: + name: + description: Resource display name + type: string + slug: + description: Resource slug + type: string + required: + - name + - slug + type: object + Instance: + additionalProperties: false + properties: + createdAt: + description: Timestamp when the instance was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this instance + readOnly: true + customerId: + description: ID of the customer associated with this instance + examples: + - 123e4567-e89b-12d3-a456-426614174003 + type: string + customerSlug: + description: Slug of the customer associated with this instance + examples: + - acme-corp + readOnly: true + type: string + deploymentZoneId: + description: ID of the deployment zone associated with this instance (optional) + examples: + - 123e4567-e89b-12d3-a456-426614174005 + type: string + deploymentZoneSlug: + description: Slug of the deployment zone associated with this instance (optional) + examples: + - aws-us-west-2 + readOnly: true + type: string + description: + description: Brief description of the instance + examples: + - This is a sample instance description + type: string + endLicenseDate: + description: End date of the license for this instance + examples: + - "2024-10-01T12:00:00Z" + format: date-time + type: string + id: + description: Unique identifier for the instance + examples: + - 123e4567-e89b-12d3-a2456-426614174000 + readOnly: true + type: string + integrations: + additionalProperties: + $ref: "#/components/schemas/InstanceIntegration" + description: Integrations grouped by adapter name + type: object + licenseId: + description: ID of the license version this instance is pinned to. An ARCHIVED version cannot be assigned, on create or by changing the license (CreateInstance.LicenseArchived, UpdateInstance.LicenseArchived); an instance already on a version that is archived later keeps it. A DRAFT version can be assigned, to try it before publishing. + examples: + - 123e4567-e89b-12d3-a456-426614174004 + type: string + licenseSlug: + description: Slug of the license associated with this instance + examples: + - my-license + readOnly: true + type: string + lifecycleStage: + description: "Commercial lifecycle stage of the instance (free-form; suggested: TRIAL, ACTIVE, AT_RISK, CHURNED)" + examples: + - ACTIVE + readOnly: true + type: string + metadata: + additionalProperties: {} + description: Metadata for the instance + examples: + - key: value + type: object + name: + description: Name of the instance + examples: + - My Instance + minLength: 1 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - my-instance + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + startLicenseDate: + description: Start date of the license for this instance + examples: + - "2023-10-01T12:00:00Z" + format: date-time + type: string + status: + description: Operational status of the instance + enum: + - HEALTHY + - DEGRADED + - INCIDENT + - MAINTENANCE + examples: + - HEALTHY + readOnly: true + type: string + updatedAt: + description: Timestamp when the instance was last updated + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + updatedBy: + $ref: "#/components/schemas/User" + description: User who last updated this instance + readOnly: true + required: + - id + - createdBy + - createdAt + - updatedBy + - updatedAt + - name + - description + - customerId + - customerSlug + - licenseId + - licenseSlug + - startLicenseDate + - endLicenseDate + type: object + InstanceBody: + additionalProperties: false + properties: + customerExternalId: + description: External identifier of the related customer in the same adapter + examples: + - rec_customer_123 + type: string + deploymentZoneId: + description: Deployment zone ID + examples: + - 123e4567-e89b-12d3-a456-426614174005 + type: string + description: + description: Instance description + examples: + - This is a sample instance description + type: string + endLicenseDate: + description: License end date + examples: + - "2024-10-01T12:00:00Z" + format: date-time + type: string + error: + description: Optional integration error message + type: string + integrationMetadata: + additionalProperties: {} + description: Adapter-specific integration metadata + type: object + licenseId: + description: License ID + examples: + - 123e4567-e89b-12d3-a456-426614174004 + type: string + metadata: + additionalProperties: {} + description: Instance metadata + type: object + name: + description: Instance name + examples: + - My Instance + minLength: 1 + type: string + slug: + description: Instance slug + examples: + - my-instance + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + startLicenseDate: + description: License start date + examples: + - "2023-10-01T12:00:00Z" + format: date-time + type: string + webUrl: + description: Absolute http(s) link to the record in the third-party system + examples: + - https://app.attio.com/w/acme/record/rec_12345 + type: string + type: object + InstanceEntitlementCapExceeded: + additionalProperties: false + properties: + entitlement_slug: + description: URL-friendly identifier of the entitlement + type: string + overage: + description: Amount by which usage exceeds the cap + format: double + type: number + threshold: + description: The entitlement cap + format: double + type: number + value: + description: Current usage value, including overage + format: double + type: number + required: + - entitlement_slug + - threshold + - value + - overage + type: object + InstanceEntitlementUsagePeriodRolledOver: + additionalProperties: false + properties: + closed_period_end: + format: date-time + type: + - string + - "null" + closed_period_start: + format: date-time + type: + - string + - "null" + entitlement_id: + type: string + entitlement_slug: + type: string + event_count: + format: int32 + type: integer + instance_id: + type: string + is_synthetic: + type: boolean + new_period_start: + format: date-time + type: string + value: + format: double + type: number + required: + - entitlement_id + - entitlement_slug + - instance_id + - value + - event_count + - closed_period_start + - closed_period_end + - new_period_start + - is_synthetic + type: object + InstanceEntitlementUsageReportAccepted: + additionalProperties: false + properties: + behavior: + description: "Report behavior: append folds the value into the stored total, set overwrites it" + enum: + - append + - set + type: string + entitlement_slug: + description: URL-friendly identifier of the entitlement the usage was reported against + type: string + event_count: + description: Number of reports folded into the current usage window, including this one + format: int32 + type: integer + status: + description: Whether the report was persisted + enum: + - ACCEPTED + - REJECTED + type: string + value: + description: Value carried by the report + format: double + type: number + required: + - entitlement_slug + - value + - behavior + - event_count + - status + type: object + InstanceEntitlementUsageWarningThresholdReached: + additionalProperties: false + properties: + boundary: + description: Usage value at which the warning fires (cap * warningThresholdPercent / 100) + format: double + type: number + entitlement_slug: + description: URL-friendly identifier of the entitlement + type: string + threshold: + description: The entitlement cap + format: double + type: number + value: + description: Current usage value + format: double + type: number + required: + - entitlement_slug + - threshold + - boundary + - value + type: object + InstanceEntitlementValueGet: + additionalProperties: false + properties: + entitlement_id: + description: Unique identifier for the entitlement + type: string + entitlement_slug: + description: URL-friendly identifier of the entitlement + type: string + instance_id: + description: Unique identifier for the instance the value was read for + type: string + license_id: + description: Unique identifier for the license carrying the entitlement + type: string + organization_id: + description: Unique identifier for the owning organization + type: string + timestamp: + description: When the value was read + format: date-time + type: string + type: + description: Discriminator of the resolved entitlement value + examples: + - number + type: string + value: + description: Resolved entitlement value at read time + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + required: + - entitlement_id + - instance_id + - license_id + - organization_id + - type + - value + - timestamp + - entitlement_slug + type: object + InstanceIntegration: + additionalProperties: false + properties: + external_id: + description: External identifier in the third-party adapter + examples: + - rec_12345 + type: string + last_error: + description: Last synchronization error message, if any + type: string + metadata: + additionalProperties: {} + description: Adapter-specific integration metadata + type: object + synced_at: + description: Timestamp of the last synchronization with the adapter + format: date-time + readOnly: true + type: string + web_url: + description: Absolute http(s) link to the record in the third-party system + examples: + - https://app.attio.com/w/acme/workspace/rec_12345 + type: string + required: + - external_id + - synced_at + type: object + InstanceIntegrationResource: + additionalProperties: false + properties: + customerExternalId: + description: External identifier of the related customer in the same adapter + examples: + - rec_customer_123 + type: string + deploymentZoneId: + description: Deployment zone ID + examples: + - 123e4567-e89b-12d3-a456-426614174005 + type: string + description: + description: Instance description + examples: + - This is a sample instance description + type: string + endLicenseDate: + description: License end date + examples: + - "2024-10-01T12:00:00Z" + format: date-time + type: string + error: + description: Last synchronization error for this integration, if any + type: string + integrationMetadata: + additionalProperties: {} + description: Adapter-specific instance integration metadata + type: object + licenseId: + description: License ID + examples: + - 123e4567-e89b-12d3-a456-426614174004 + type: string + name: + description: Instance name + examples: + - My Instance + type: string + slug: + description: Instance slug + examples: + - my-instance + type: string + startLicenseDate: + description: License start date + examples: + - "2023-10-01T12:00:00Z" + format: date-time + type: string + webUrl: + description: Absolute http(s) link to the record in the third-party system + examples: + - https://app.attio.com/w/acme/workspace/rec_12345 + type: string + required: + - description + - endLicenseDate + - licenseId + - integrationMetadata + - name + - slug + - startLicenseDate + type: object + InstanceLifecycleStageChanged: + additionalProperties: false + properties: + changed_by: + format: uuid + type: string + instance_id: + format: uuid + type: string + new_lifecycle_stage: + type: + - string + - "null" + previous_lifecycle_stage: + type: + - string + - "null" + required: + - instance_id + - previous_lifecycle_stage + - new_lifecycle_stage + - changed_by + type: object + InstanceStatusChanged: + additionalProperties: false + properties: + changed_by: + format: uuid + type: string + instance_id: + format: uuid + type: string + new_status: + enum: + - HEALTHY + - DEGRADED + - INCIDENT + - MAINTENANCE + type: string + previous_status: + enum: + - HEALTHY + - DEGRADED + - INCIDENT + - MAINTENANCE + type: string + required: + - instance_id + - previous_status + - new_status + - changed_by + type: object + License: + additionalProperties: false + properties: + createdAt: + description: Timestamp when this license was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + description: + description: Detailed description of the license capabilities and restrictions + examples: + - This license provides access to all enterprise features including advanced analytics, priority support, and unlimited users. + maxLength: 500 + type: string + familyId: + description: "Identifier of the license family this license is a version of. Stable across renames and new versions: two licenses sharing it are the same product. Always present in responses. On create, optional: names the family this license is a new version of, as familySlug does by slug; omit both to create a new family. On update, optional and must be the license's own family: a version cannot move between families (UpdateLicense.FamilyNotReassignable)." + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + familySlug: + description: "Write-only on create: slug of the family this license is a new version of. familyId names the same family by identifier; sent together they must agree. Omit both to create a new family, of which this license becomes version 1. Never returned -- see familyId. Refused on update: a version cannot move between families." + examples: + - enterprise-license + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + writeOnly: true + id: + description: Unique identifier for the license + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + isDefault: + description: "Whether this version is the one its family puts forward: at most one version per family, and only a PUBLISHED one (CreateLicense.DefaultMustBePublished, UpdateLicense.DefaultMustBePublished). Resolution hands it out ahead of the highest-numbered published version, so it keeps being served when a newer version is published. Setting it takes the flag from the family's previous default, which gets a LICENSE_UPDATED event of its own. On update, sending false unsets it; the default cannot be archived until another version takes its place or the flag is unset (ArchiveLicense.DefaultMustBePublished)." + examples: + - false + type: boolean + lifecycleState: + description: Whether this version may be served. Always present in responses. Optional on create, where it is DRAFT or PUBLISHED and PUBLISHED when omitted; ARCHIVED is refused (CreateLicense.LifecycleStateNotSettable), since a version is archived by withdrawing it from sale. After that it changes only through publish-license (DRAFT to PUBLISHED), archive-license (PUBLISHED to ARCHIVED) and unarchive-license (ARCHIVED to PUBLISHED); update accepts the stored state and refuses any other (UpdateLicense.LifecycleStateNotSettable). Only a PUBLISHED version can be the family's default. An ARCHIVED version cannot be assigned to an instance (CreateInstance.LicenseArchived, UpdateInstance.LicenseArchived); instances already on it keep it. + enum: + - DRAFT + - PUBLISHED + - ARCHIVED + examples: + - PUBLISHED + type: string + name: + description: Display name of the license + examples: + - Enterprise License + maxLength: 100 + minLength: 1 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. When omitted, a new version of an existing family takes {familySlug}-v{version}, and any other license gets one generated from its name. A license that opens a new family gives the family its slug. + examples: + - enterprise-license + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + type: + description: Type of the license determining its capabilities and restrictions + enum: + - DEVELOPMENT + - TRIAL + - PAID + - COMMUNITY + examples: + - paid + type: string + updatedAt: + description: Timestamp when this license was last updated + examples: + - "2023-10-02T12:00:00Z" + format: date-time + readOnly: true + type: string + version: + description: "Version of the license within its family, assigned by the server when the version is created and never changed afterwards: the version's derived slug ({familySlug}-v{version}, see slug) is built from it. A number is never reused within a family, even after its version is deleted. Create refuses any value (CreateLicense.VersionNotSettable); update accepts only the stored one, so a representation can be written back as it was read (UpdateLicense.VersionNotSettable)." + examples: + - "1" + readOnly: true + type: string + versionName: + description: Optional human-readable name for the license version. Defaults to "Version - ". + examples: + - Winter 2024 Release + maxLength: 50 + type: string + required: + - id + - name + - description + - type + - version + - isDefault + - createdAt + - updatedAt + type: object + LicenseEntitlement: + additionalProperties: false + properties: + createdAt: + description: Timestamp when this entitlement was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this entitlement + readOnly: true + entitlementGroups: + description: Groups associated with this entitlement + items: + $ref: "#/components/schemas/EntitlementGroupSummary" + readOnly: true + type: + - array + - "null" + entitlementName: + description: Name of the entitlement feature or service + examples: + - Premium Support + readOnly: true + type: string + entitlementSlug: + description: URL-friendly identifier for the entitlement + examples: + - premium-support + format: string + type: string + entitlementType: + description: Type of entitlement (BOOLEAN, NUMBER, or CONFIG) + enum: + - BOOLEAN + - NUMBER + - CONFIG + examples: + - NUMBER + readOnly: true + type: string + licenseId: + description: Unique identifier for the license this entitlement belongs to + examples: + - a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6 + readOnly: true + type: string + licenseSlug: + description: URL-friendly identifier for the license this entitlement belongs to + examples: + - enterprise-license + readOnly: true + type: string + limitCapExceededOveragePercent: + description: "Derives this grant's enforcement from its own numeric value: -1 when the value is unlimited (no cap, no overage); otherwise 0 rejects usage above the value (hard limit) and a positive percentage allows usage to exceed it by that much before being rejected (soft limit). Null for non-numeric (BOOLEAN/CONFIG) grants." + examples: + - 10 + format: int32 + type: integer + updatedAt: + description: Timestamp when this entitlement was last updated + examples: + - "2023-10-02T12:00:00Z" + format: date-time + readOnly: true + type: string + updatedBy: + $ref: "#/components/schemas/User" + description: User who last updated this entitlement + readOnly: true + value: + description: "Typed entitlement value. Supported shapes: {type:number,value:number}, {type:boolean,value:boolean}, {type:object,value:{...}}" + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + required: + - entitlementName + - licenseId + - licenseSlug + - createdBy + - createdAt + - updatedBy + - updatedAt + - value + type: object + LicenseFamilyView: + additionalProperties: false + properties: + createdAt: + description: Timestamp when this family was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + currentVersion: + $ref: "#/components/schemas/License" + description: "The version this read is about. By default the version the family resolves to: its default version if it has one, otherwise its highest-numbered PUBLISHED version. Under ?version=N it is the version asked for instead, whatever its lifecycle state. Absent when the family has no PUBLISHED version, which only a list or an include=versions read returns." + id: + description: Unique identifier for the license family + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + slug: + description: URL-friendly identifier of the family, unique per organization and stable across renames and new versions. Deleting the family's last version deletes the family and frees the slug. + examples: + - enterprise-license + type: string + updatedAt: + description: Timestamp when a version was last added to this family, or when the family was created. Renames, lifecycle moves and default changes update the versions' own updatedAt, not this one. + examples: + - "2023-10-02T12:00:00Z" + format: date-time + readOnly: true + type: string + versionCount: + description: Number of versions in this family, whatever their lifecycle state + examples: + - 3 + format: int32 + type: integer + versions: + description: "Every version of the family, oldest first. Only returned when explicitly requested with include=versions, and then never null: a family always has at least one version." + items: + $ref: "#/components/schemas/License" + type: array + required: + - id + - slug + - versionCount + - createdAt + - updatedAt + type: object + List: + additionalProperties: false + properties: + data: + description: The page, newest first + items: + $ref: "#/components/schemas/Notification" + type: array + nextCursor: + description: Opaque cursor for the next page; null on the last page + type: + - string + - "null" + unreadCount: + description: Unread notifications, capped (see MaxUnreadCount) + format: int64 + type: integer + required: + - data + - nextCursor + - unreadCount + type: object + ManifestEnvelope: + additionalProperties: false + properties: + flags: + items: + $ref: "#/components/schemas/ManifestFlag" + type: + - array + - "null" + required: + - flags + type: object + ManifestFlag: + additionalProperties: false + properties: + defaultValue: {} + description: + type: string + key: + type: string + name: + type: string + type: + type: string + required: + - key + - type + - defaultValue + type: object + MarkReadResult: + additionalProperties: false + properties: + unreadCount: + description: Unread notifications after the change + format: int64 + type: integer + updated: + description: Notifications newly marked read + format: int64 + type: integer + required: + - updated + - unreadCount + type: object + MetadataField: + additionalProperties: false + properties: + archivedAt: + description: Timestamp when the field was archived (soft delete). Absent if active. + format: date-time + readOnly: true + type: string + createdAt: + description: Timestamp when the field was created + examples: + - "2026-05-26T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this field + readOnly: true + displayOrder: + description: Sort order in the admin UI; ties are broken by createdAt ASC. Settable only through POST /metadata-fields/reorder; never echoed back on update. + examples: + - 0 + format: int32 + type: integer + id: + description: Unique identifier + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + jsonSchema: + additionalProperties: {} + description: "JSON Schema 2020-12 document describing the value space. Example: {\"type\":\"string\",\"enum\":[\"a\",\"b\"]}" + type: object + key: + description: Stable identifier, unique per (organization, resource type) on non-archived rows. Immutable after creation; update must echo back the stored value. + examples: + - region + maxLength: 100 + minLength: 1 + type: string + label: + description: Human-readable label shown in the admin UI + examples: + - Region + maxLength: 100 + minLength: 1 + type: string + resourceType: + description: Resource type this field applies to. Immutable after creation; update must echo back the stored value. + enum: + - DEPLOYMENT_ZONE + - INSTANCE + type: string + updatedAt: + description: Timestamp when the field was last updated + examples: + - "2026-05-26T12:00:00Z" + format: date-time + readOnly: true + type: string + updatedBy: + $ref: "#/components/schemas/User" + description: User who last updated this field + readOnly: true + required: + - id + - resourceType + - key + - label + - jsonSchema + - createdBy + - createdAt + - updatedBy + - updatedAt + type: object + MetadataFieldReordered: + additionalProperties: false + properties: + ids: + description: Metadata field ids in their new display order + items: + type: string + type: + - array + - "null" + required: + - ids + type: object + MetadataFieldSchemaProposal: + additionalProperties: false + properties: + jsonSchema: + additionalProperties: {} + description: Candidate JSON Schema 2020-12 document to evaluate existing values against. + type: object + required: + - jsonSchema + type: object + Notification: + additionalProperties: false + properties: + actionUrl: + description: Where clicking the notification goes + type: string + body: + description: Rendered detail line, when the event has one + type: string + createdAt: + description: When the event occurred + format: date-time + type: string + eventName: + description: Event this notification reports + examples: + - INSTANCE_DEPLOYED + type: string + eventType: + description: CloudEvents type of the same event -- the key it is published under in this document's webhooks section, so a client can line a notification up with the payload schema and the description already published there + examples: + - com.kaiten.instance.v1.deployed + type: string + id: + description: Identifier of the underlying audit trail entry + type: string + objectType: + description: What the notification is about -- the kind of page actionUrl opens, and what the list's objectType filter matches + examples: + - instance + type: string + readAt: + description: When this user read it; absent means unread + format: date-time + type: string + title: + description: Rendered headline + type: string + required: + - id + - eventName + - eventType + - objectType + - title + - createdAt + type: object + NumberEntitlementValue: + additionalProperties: false + properties: + event_count: + description: Number of events accumulated (server-computed) + examples: + - 2 + format: int32 + readOnly: true + type: integer + type: + description: Value type discriminator + enum: + - number + examples: + - number + type: string + value: + description: Numeric value + examples: + - 42.5 + format: double + type: number + required: + - type + - value + type: object + PageAuditTrail: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/AuditTrail" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageComponent: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Component" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageCustomer: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Customer" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageDeploymentZone: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/DeploymentZone" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageEntitlement: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Entitlement" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageEntitlementGroup: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/EntitlementGroup" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageFeatureFlag: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/FeatureFlag" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageInstance: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Instance" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageLicense: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/License" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageLicenseEntitlement: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/LicenseEntitlement" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageLicenseFamilyView: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/LicenseFamilyView" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageMetadataField: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/MetadataField" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageRelease: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Release" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageServiceAccount: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/ServiceAccount" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PageToken: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Token" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object + PatchInstanceBody: + additionalProperties: false + properties: + lifecycleStage: + description: "Commercial lifecycle stage (free-form; suggested: TRIAL, ACTIVE, AT_RISK, CHURNED). When omitted, the current stage is preserved; clearing it back to unset is not supported." + examples: + - AT_RISK + minLength: 1 + type: string + status: + description: Operational status of the instance + enum: + - HEALTHY + - DEGRADED + - INCIDENT + - MAINTENANCE + examples: + - INCIDENT + type: string + type: object + PlainToken: + additionalProperties: false + properties: + createdAt: + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + readOnly: true + expiresAt: + format: date-time + type: string + id: + readOnly: true + type: string + name: + type: string + revokedAt: + format: date-time + readOnly: true + type: string + revokedBy: + $ref: "#/components/schemas/User" + readOnly: true + scopes: + items: + type: string + type: + - array + - "null" + serviceAccountId: + readOnly: true + type: string + slug: + description: Optional URL-friendly identifier, unique per service account. Auto-generated if not provided. + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + token: + readOnly: true + type: string + required: + - name + - scopes + - createdAt + - createdBy + type: object + PreferenceChoices: + additionalProperties: false + properties: + events: + description: Events to set; events left out keep their current value + items: + $ref: "#/components/schemas/PreferenceUpdate" + type: + - array + - "null" + required: + - events + type: object + PreferenceEvent: + additionalProperties: false + properties: + channels: + additionalProperties: + type: boolean + description: Effective value per channel + type: object + eventName: + examples: + - INSTANCE_DEPLOYED + type: string + eventType: + description: CloudEvents type of the same event; see Notification.eventType + examples: + - com.kaiten.instance.v1.deployed + type: string + group: + description: Catalogue group the client renders this under + examples: + - deployments + type: string + label: + description: Human-readable event name + type: string + required: + - eventName + - eventType + - label + - group + - channels + type: object + PreferenceMatrix: + additionalProperties: false + properties: + channels: + description: Channels this deployment serves, in display order + items: + type: string + type: array + events: + description: Every notifiable event + items: + $ref: "#/components/schemas/PreferenceEvent" + type: array + required: + - channels + - events + type: object + PreferenceUpdate: + additionalProperties: false + properties: + channels: + additionalProperties: + type: boolean + type: object + eventName: + type: string + required: + - eventName + - channels + type: object + Problem: + additionalProperties: false + properties: + code: + description: Stable, machine-readable error code. + examples: + - License.NotFound + type: string + detail: + description: A human-readable explanation specific to this occurrence of the problem. + examples: + - license not found + type: string + errorId: + description: Correlation id for the server-side log entry describing the withheld cause. + examples: + - 3f6b1a2c-7c1e-4d0b-9d5f-2b0a1c4e8d31 + type: string + errors: + description: Optional list of individual error details + items: + $ref: "#/components/schemas/ErrorDetail" + type: + - array + - "null" + instance: + description: A URI reference that identifies the specific occurrence of the problem. + examples: + - /api/licenses/enterprise + type: string + status: + description: HTTP status code + examples: + - 404 + format: int64 + type: integer + title: + description: A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + examples: + - Not Found + type: string + type: + default: about:blank + description: A URI reference to human-readable documentation for the error. + examples: + - https://tools.ietf.org/html/rfc7231#section-6.5.4 + format: uri + type: string + type: object + ReadSelection: + additionalProperties: false + properties: + all: + description: Mark every notification read, up to now + type: boolean + ids: + description: Notifications to mark read + items: + type: string + maxItems: 200 + type: + - array + - "null" + type: object + Release: + additionalProperties: false + properties: + componentIds: + description: List of component IDs to link to this release + examples: + - - 123e4567-e89b-12d3-a456-426614174000 + items: + type: string + type: + - array + - "null" + writeOnly: true + components: + description: List of components included in this release, fixed at creation + items: + $ref: "#/components/schemas/Component" + readOnly: true + type: + - array + - "null" + createdAt: + description: Timestamp when the release was created + examples: + - "2023-10-01T12:00:00Z" + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + description: User who created this release + readOnly: true + description: + description: Description of the release + examples: + - "This release fixes everything. CHANGELOG: stuff was done" + maxLength: 500 + type: string + id: + description: Unique identifier for the release + examples: + - 123e4567-e89b-12d3-a456-426614174000 + readOnly: true + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + examples: + - v1-0-0 + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + version: + description: Version of the release + examples: + - v1.0.0 + maxLength: 100 + minLength: 1 + type: string + required: + - id + - version + - createdBy + - createdAt + type: object + ReorderMetadataFieldsInput: + additionalProperties: false + properties: + ids: + items: + type: string + type: + - array + - "null" + required: + - ids + type: object + ReportEntitlementUsageBody: + additionalProperties: false + properties: + behavior: + description: "Report behavior: append folds the value into the stored total through the aggregation method; set overwrites it" + enum: + - append + - set + type: string + metadata: + additionalProperties: {} + description: Optional metadata for the usage report + type: object + value: + description: Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. + discriminator: + mapping: + boolean: "#/components/schemas/BooleanEntitlementValue" + number: "#/components/schemas/NumberEntitlementValue" + object: "#/components/schemas/ConfigEntitlementValue" + propertyName: type + oneOf: + - $ref: "#/components/schemas/NumberEntitlementValue" + - $ref: "#/components/schemas/BooleanEntitlementValue" + - $ref: "#/components/schemas/ConfigEntitlementValue" + required: + - value + type: object + RolloutDate: + additionalProperties: false + properties: + end: + $ref: "#/components/schemas/RolloutStep" + start: + $ref: "#/components/schemas/RolloutStep" + type: + enum: + - rollout_date + type: string + required: + - type + - start + - end + type: object + RolloutDateTargeting: + additionalProperties: false + properties: + end: + $ref: "#/components/schemas/RolloutStep" + examples: + - date: "2024-01-31T23:59:59Z" + percentage: 100 + variant: variant_a + name: + examples: + - Targeting Rule 1 + type: string + rule: + description: CEL expression for basicTargeting evaluation + examples: + - user.group == 'beta' + maxLength: 10000 + type: string + start: + $ref: "#/components/schemas/RolloutStep" + examples: + - date: "2024-01-01T00:00:00Z" + percentage: 0 + variant: variant_a + type: + enum: + - rollout_date + type: string + required: + - type + - name + - rule + - start + - end + type: object + RolloutPercentage: + additionalProperties: false + properties: + distribution: + additionalProperties: + format: int64 + type: integer + type: object + type: + enum: + - rollout_percentage + type: string + required: + - type + - distribution + type: object + RolloutPercentageTargeting: + additionalProperties: false + properties: + distribution: + additionalProperties: + format: int64 + type: integer + examples: + - variant_a: 70 + variant_b: 30 + type: object + name: + examples: + - Targeting Rule 1234 + type: string + rule: + description: CEL expression for basicTargeting evaluation + examples: + - user.group == 'beta' + maxLength: 10000 + type: string + type: + enum: + - rollout_percentage + type: string + required: + - type + - name + - rule + - distribution + type: object + RolloutStep: + additionalProperties: false + properties: + date: + examples: + - "2024-01-15T10:00:00Z" + format: date-time + type: string + percentage: + examples: + - 50 + format: int64 + type: integer + variant: + examples: + - variant_b + type: string + required: + - variant + - percentage + - date + type: object + ServiceAccount: + additionalProperties: false + properties: + createdAt: + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + readOnly: true + externalId: + readOnly: true + type: string + id: + readOnly: true + type: string + name: + minLength: 1 + type: string + slug: + description: Optional URL-friendly identifier, unique per organization. Auto-generated if not provided. + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + tokens: + items: + $ref: "#/components/schemas/Token" + readOnly: true + type: + - array + - "null" + required: + - name + - tokens + type: object + SystemTokenIssuance: + additionalProperties: false + properties: + credentialKind: + description: Credential class that performed the mint; "platform" is the system:kaiten identity + examples: + - platform + type: string + expiresAt: + description: Absent when the minted credential does not expire + format: date-time + type: string + issuedAt: + description: When the credential was minted + format: date-time + type: string + issuedTokenId: + description: The credential that was minted + type: string + name: + description: Human-readable label the caller gave the minted credential + examples: + - ci-deploy + type: string + platformTokenId: + description: Platform credential that performed the mint; revoking it revokes everything it issued + type: string + scopes: + description: Scopes granted to the minted credential + examples: + - - read:customers + items: + type: string + type: + - array + - "null" + slug: + description: Server-assigned identifier of the minted credential + examples: + - system-kaiten-9f2c1a + type: string + required: + - credentialKind + - platformTokenId + - issuedTokenId + - name + - slug + - scopes + - issuedAt + type: object + TargetingContext: + additionalProperties: false + properties: + roots: + description: "Identifiers the server guarantees, and what is reachable below each. Not a closed set: a host may target on its own attributes too" + items: + $ref: "#/components/schemas/TargetingContextNode" + type: array + required: + - roots + type: object + TargetingContextNode: + additionalProperties: false + properties: + description: + description: What this value means, shown by the editor + type: string + fields: + description: Named children, for an object + items: + $ref: "#/components/schemas/TargetingContextNode" + type: array + knownKeys: + description: Keys this map currently has, for completion. Not a closed set + items: + type: string + type: array + name: + description: Name as it is written in a rule + type: string + optional: + description: Whether this value may be absent, in which case has() is worth using + type: boolean + type: + description: Shape of this node's value + enum: + - string + - number + - boolean + - object + - map + - dyn + type: string + values: + $ref: "#/components/schemas/TargetingContextNode" + description: Shape shared by every entry, for a map + required: + - name + - type + type: object + TargetingRuleDraft: + additionalProperties: false + properties: + rule: + description: The CEL targeting rule to check. May be blank, which is itself a mistake this reports + examples: + - __kaiten.license.familySlug == 'scale' + maxLength: 10000 + type: string + required: + - rule + type: object + TargetingRuleIssue: + additionalProperties: false + properties: + column: + description: 1-based column the problem starts on, or 0 when it has no position + format: int64 + type: integer + endColumn: + description: 1-based column just past the problem, or 0 when only a point is known + format: int64 + type: integer + endLine: + description: 1-based line the problem ends on, or 0 when only a point is known + format: int64 + type: integer + line: + description: 1-based line the problem starts on, or 0 when it has no position + format: int64 + type: integer + message: + description: What is wrong, in the same words the write path refuses it with + type: string + required: + - message + - line + - column + type: object + TargetingRuleRehearsal: + additionalProperties: false + properties: + evaluationError: + description: Why the engine could not answer, when it could not — e.g. the rule reads a host attribute this context does not carry. At a real evaluation the rule would simply not match + type: string + facts: + additionalProperties: {} + description: The __kaiten namespace as enriched for this context — what a rule actually reads. Empty sub-roots mean the context resolved nothing for them + type: object + issues: + description: Problems the lint found. The rule is only run when there are none + items: + $ref: "#/components/schemas/TargetingRuleIssue" + type: array + matched: + description: Whether the rule matched this context. Meaningful only when valid and evaluationError is empty + type: boolean + valid: + description: Whether the rule lints clean + type: boolean + required: + - facts + - issues + - valid + - matched + type: object + TargetingRuleTrial: + additionalProperties: false + properties: + context: + additionalProperties: {} + description: The evaluation context to rehearse against, as a client would send it. Anything under __kaiten is discarded, exactly as at a real evaluation + type: object + rule: + description: The CEL targeting rule to rehearse + examples: + - __kaiten.license.familySlug == 'scale' + maxLength: 10000 + type: string + targetingKey: + description: The key the evaluation would be bucketed on. Resolves license and entitlement facts when it names a customer slug + type: string + required: + - rule + type: object + TargetingRuleVerdict: + additionalProperties: false + properties: + issues: + description: Every problem found, positioned where it is written. Empty when the rule is accepted + items: + $ref: "#/components/schemas/TargetingRuleIssue" + type: array + valid: + description: Whether the write path would accept this rule + type: boolean + required: + - issues + - valid + type: object + Targetings: + description: Polymorphic list of targeting rules (Basic, RolloutDate, RolloutPercentage) + items: + anyOf: + - $ref: "#/components/schemas/BasicTargeting" + - $ref: "#/components/schemas/RolloutDateTargeting" + - $ref: "#/components/schemas/RolloutPercentageTargeting" + type: + - array + - "null" + Token: + additionalProperties: false + properties: + createdAt: + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + readOnly: true + expiresAt: + format: date-time + type: string + id: + readOnly: true + type: string + name: + type: string + revokedAt: + format: date-time + readOnly: true + type: string + revokedBy: + $ref: "#/components/schemas/User" + readOnly: true + scopes: + items: + type: string + type: + - array + - "null" + serviceAccountId: + readOnly: true + type: string + slug: + description: Optional URL-friendly identifier, unique per service account. Auto-generated if not provided. + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + required: + - name + - scopes + - createdAt + - createdBy + type: object + User: + additionalProperties: false + properties: + id: + description: ID of the user + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + name: + description: Name of the user + examples: + - John Doe + type: string + required: + - id + type: object + Variant: + additionalProperties: false + properties: + description: + type: string + name: + type: string + value: {} + required: + - name + - description + - value + type: object + securitySchemes: + bearerAuth: + bearerFormat: JWT + description: "A JWT verified by the proxy in front of this API, or a Kaiten access token (`ksh_...`) — send either as `Authorization: Bearer `. The scopes listed on each operation are the ones the token must carry; `write:` implies `read:`, and `read:*` / `write:*` are wildcards over every module. `x-kaiten-scopes` lists every scope such a token can carry, including those enforced by a service in front of this API rather than by one of its operations." + scheme: bearer + type: http + x-kaiten-scopes: + - read:components + - read:customers + - read:deployment_zones + - read:entitlements + - read:feature_flags + - read:instances + - read:licenses + - read:metadata_fields + - read:notifications + - read:organizations + - read:releases + - read:tokens + - read:webhooks + - write:components + - write:customers + - write:deployment_zones + - write:entitlements + - write:feature_flags + - write:instances + - write:licenses + - write:metadata_fields + - write:notifications + - write:organizations + - write:releases + - write:tokens + - write:webhooks +info: + contact: + email: support@kaiten.sh + name: Kaiten Team + description: A RESTFul API for managing Kaiten features. + title: Kaiten API + version: 1.0.0 +openapi: 3.1.0 +paths: + /components: + get: + description: Returns a cursor-paginated page of components for the current organization. + operationId: list-components + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageComponent" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:components + summary: List components + tags: + - components + post: + description: Create a new standalone component. Optionally derive it from a previous component version. + operationId: create-component + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Component" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Component" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:components + summary: Create a new component + tags: + - components + /components/{componentSlug}: + delete: + description: Delete a component by slug if it is not linked to any release. + operationId: delete-component + parameters: + - description: Component slug + in: path + name: componentSlug + required: true + schema: + description: Component slug + examples: + - api-gateway-v1-2-3 + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:components + summary: Delete a component + tags: + - components + get: + description: Returns a single component by its slug. + operationId: get-component + parameters: + - description: Component slug + in: path + name: componentSlug + required: true + schema: + description: Component slug + examples: + - api-gateway-v1-2-3 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Component" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:components + summary: Get a component by slug + tags: + - components + put: + description: Update a component in place when unlinked, or create a new version when it is already linked to releases. + operationId: update-component + parameters: + - description: Component slug + in: path + name: componentSlug + required: true + schema: + description: Component slug + examples: + - api-gateway-v1-2-3 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Component" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Component" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:components + summary: Update a component + tags: + - components + /connectors: + get: + description: Returns every registered connector with its version and settings schema, ordered by name. The registry is platform-wide rather than per-organization and holds one row per connector, so the response is not paginated. + operationId: list-connectors + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Connector" + type: + - array + - "null" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:organizations + summary: List registered connectors + tags: + - connectors + /connectors/{connectorName}: + get: + description: Retrieves a registered connector resource including version and settings schema + operationId: get-connector + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Connector" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:organizations + summary: Get connector registration + tags: + - connectors + /connectors/{connectorName}/activation: + delete: + description: "Deactivates a connector for the authenticated organization. Idempotent, and never refused for an unregistered or unlicensed connector: an organization must always be able to stop using something." + operationId: deactivate-connector + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:organizations + summary: Deactivate connector + tags: + - connectors + put: + description: "Activates a registered connector for the authenticated organization. Idempotent: activating an already-active connector succeeds without changing when it was first activated. Refused when the organization's licence does not include the connector." + operationId: activate-connector + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectorActivation" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:organizations + summary: Activate connector + tags: + - connectors + /connectors/{connectorName}/settings: + delete: + description: Delete connector settings for the authenticated organization + operationId: delete-connector-settings + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:organizations + summary: Delete connector settings + tags: + - connectors + get: + description: Get settings for a connector scoped to the authenticated organization. Write-only fields (flagged `writeOnly` in the connector settings schema) are returned redacted as `***`. + operationId: get-connector-settings + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectorSettings" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:organizations + summary: Get connector settings + tags: + - connectors + put: + description: Create or update connector settings for the authenticated organization. Write-only fields (flagged `writeOnly` in the connector settings schema) may be omitted or sent as `***` to keep the stored value; they are returned redacted. + operationId: update-connector-settings + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectorSettings" + description: Connector settings payload to upsert + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectorSettings" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:organizations + summary: Upsert connector settings + tags: + - connectors + /connectors/{connectorName}/settings/schema: + get: + description: Get the JSON schema used to validate connector settings payloads + operationId: get-connector-settings-schema + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectorSettingsSchema" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:organizations + summary: Get connector settings schema + tags: + - connectors + /connectors/{connectorName}/state: + get: + description: "Returns whether the connector is registered in this deployment, whether the authenticated organization's licence includes it, and whether the organization has activated it. Answers 200 with `available: false` for a connector this deployment does not have." + operationId: get-connector-state + parameters: + - description: Stable connector name + in: path + name: connectorName + required: true + schema: + description: Stable connector name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectorState" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - read:organizations + summary: Get connector state for this organization + tags: + - connectors + /customers: + get: + description: Returns a cursor-paginated page of customers for the current organization. + operationId: list-customers + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageCustomer" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:customers + summary: List customers + tags: + - customers + post: + description: Create a new customer with the provided details. + operationId: create-customer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Customer" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Customer" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:customers + summary: Create a new customer + tags: + - customers + /customers/{customerSlug}: + delete: + description: Delete a customer by their slug. + operationId: delete-customer + parameters: + - in: path + name: customerSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:customers + summary: Delete a customer + tags: + - customers + get: + description: Returns a single customer by their slug. + operationId: get-customer + parameters: + - in: path + name: customerSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Customer" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:customers + summary: Get a customer by slug + tags: + - customers + put: + description: Update a customer's details. Slug and integrations are not renameable/settable through this endpoint -- omit them, or send the values already on record. + operationId: update-customer + parameters: + - in: path + name: customerSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Customer" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:customers + summary: Update a customer + tags: + - customers + /customers/{customerSlug}/integrations/{integrationName}: + delete: + description: Delete a single integration for a customer + operationId: delete-customer-integration + parameters: + - description: Customer slug + in: path + name: customerSlug + required: true + schema: + description: Customer slug + examples: + - acme-corp + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:customers + summary: Delete a customer integration + tags: + - customers + get: + description: Retrieve a single integration for a customer + operationId: get-customer-integration + parameters: + - description: Customer slug + in: path + name: customerSlug + required: true + schema: + description: Customer slug + examples: + - acme-corp + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegration" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:customers + summary: Get a customer integration + tags: + - customers + post: + description: Create a single integration for a customer + operationId: create-customer-integration + parameters: + - description: Customer slug + in: path + name: customerSlug + required: true + schema: + description: Customer slug + examples: + - acme-corp + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegration" + description: Integration payload + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegration" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:customers + summary: Create a customer integration + tags: + - customers + put: + description: Update a single integration for a customer + operationId: update-customer-integration + parameters: + - description: Customer slug + in: path + name: customerSlug + required: true + schema: + description: Customer slug + examples: + - acme-corp + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegration" + description: Integration payload + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegration" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:customers + summary: Update a customer integration + tags: + - customers + /deployment-zones: + get: + description: Returns a cursor-paginated page of deployment zones for the current organization. A deployment zone is one place a customer runs a release -- a target they name and classify themselves, holding the release currently on it. It is not an installation of Kaiten; that sense of the term belongs to operations and is not reachable through this API. + operationId: list-deployment-zones + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageDeploymentZone" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:deployment_zones + summary: List deployment zones + tags: + - deploymentZones + post: + description: Create a new deployment zone with the provided details. Supplying releaseId records the zone's first deployment. A deployment zone is one place a customer runs a release -- a target they name and classify themselves, holding the release currently on it. It is not an installation of Kaiten; that sense of the term belongs to operations and is not reachable through this API. + operationId: create-deployment-zone + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentZone" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentZone" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:deployment_zones + summary: Create a new deployment zone + tags: + - deploymentZones + /deployment-zones/{deploymentZoneSlug}: + delete: + description: Delete a deployment zone by their slug. + operationId: delete-deployment-zone + parameters: + - in: path + name: deploymentZoneSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:deployment_zones + summary: Delete a deployment zone + tags: + - deploymentZones + get: + description: Returns a single deployment zone by their slug. + operationId: get-deployment-zone-by-slug + parameters: + - in: path + name: deploymentZoneSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentZone" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:deployment_zones + summary: Get a deployment zone by slug + tags: + - deploymentZones + put: + description: "Update a deployment zone's details. Changing releaseId records a deployment: the zone's current release becomes the new one and the change is appended to its deployment history, including when the new release is one the zone already ran (a rollback). Omitting releaseId, or echoing back the current one, leaves the history untouched." + operationId: update-deploymentZone + parameters: + - in: path + name: deploymentZoneSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentZone" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:deployment_zones + summary: Update a deployment zone + tags: + - deploymentZones + /entitlement-groups: + get: + description: Returns a cursor-paginated page of entitlement groups for the current organization. + operationId: list-entitlement-groups + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageEntitlementGroup" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:entitlements + summary: List all entitlement groups + tags: + - entitlement-groups + post: + description: Create a new entitlement group to aggregate entitlements + operationId: create-entitlement-group + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementGroup" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementGroup" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:entitlements + summary: Create a new entitlement group + tags: + - entitlement-groups + /entitlement-groups/{entitlementGroupSlug}: + delete: + description: Delete an entitlement group by its slug. This does not delete the member entitlements. + operationId: delete-entitlement-group + parameters: + - in: path + name: entitlementGroupSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:entitlements + summary: Delete an entitlement group + tags: + - entitlement-groups + get: + description: Retrieve details of a specific entitlement group by its slug + operationId: get-entitlement-group + parameters: + - in: path + name: entitlementGroupSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementGroup" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:entitlements + summary: Get entitlement group by slug + tags: + - entitlement-groups + put: + description: Update an existing entitlement group with the provided details + operationId: update-entitlement-group + parameters: + - in: path + name: entitlementGroupSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementGroup" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:entitlements + summary: Update an entitlement group + tags: + - entitlement-groups + /entitlement-groups/{entitlementGroupSlug}/entitlements: + post: + description: Add an existing entitlement as a member of an entitlement group + operationId: add-entitlement-to-group + parameters: + - in: path + name: entitlementGroupSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Command" + required: true + responses: + "201": + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:entitlements + summary: Add an entitlement to a group + tags: + - entitlement-groups + /entitlement-groups/{entitlementGroupSlug}/entitlements/{entitlementSlug}: + delete: + description: Remove an entitlement from an entitlement group. The entitlement itself is not deleted. + operationId: remove-entitlement-from-group + parameters: + - in: path + name: entitlementGroupSlug + required: true + schema: + type: string + - in: path + name: entitlementSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:entitlements + summary: Remove an entitlement from a group + tags: + - entitlement-groups + /entitlement-groups/{entitlementGroupSlug}/usage: + get: + description: Retrieve the aggregated usage metrics of all member entitlements for a given instance + operationId: get-entitlement-group-usage + parameters: + - in: path + name: entitlementGroupSlug + required: true + schema: + type: string + - description: Instance slug to get usage for + explode: false + in: query + name: instance + required: true + schema: + description: Instance slug to get usage for + type: string + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/EntitlementGroupUsage" + type: + - array + - "null" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:entitlements + summary: Get aggregated usage for an entitlement group + tags: + - entitlement-groups + /entitlements: + get: + description: Returns a cursor-paginated page of entitlements for the current organization. + operationId: list-entitlements + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageEntitlement" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:entitlements + summary: List all entitlements + tags: + - entitlements + post: + description: Create a new entitlement with the provided details + operationId: create-entitlement + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Entitlement" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Entitlement" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:entitlements + summary: Create a new entitlement + tags: + - entitlements + /entitlements/{entitlementSlug}: + delete: + description: Delete an entitlement by its slug + operationId: delete-entitlement + parameters: + - in: path + name: entitlementSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:entitlements + summary: Delete an entitlement + tags: + - entitlements + get: + description: Retrieve details of a specific entitlement by its slug + operationId: get-entitlement + parameters: + - in: path + name: entitlementSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Entitlement" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:entitlements + summary: Get entitlement by slug + tags: + - entitlements + put: + description: Update an existing entitlement with the provided details + operationId: update-entitlement + parameters: + - in: path + name: entitlementSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Entitlement" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:entitlements + summary: Update an entitlement + tags: + - entitlements + /feature-flags: + get: + description: Returns a cursor-paginated page of feature flags for the given organization. + operationId: get-feature-flags + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageFeatureFlag" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:feature_flags + summary: Get a list of feature flags + tags: + - featureflags + post: + description: Create a new featureflag with the provided details + operationId: create-feature-flag + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureFlag" + description: Feature flag details + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureFlag" + description: Created feature flag + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:feature_flags + summary: Create a new featureflag + tags: + - featureflags + /feature-flags/targeting/context: + get: + description: "Returns the identifiers a targeting rule may read, with their types and descriptions, including the organization's own entitlement slugs. Intended for an editor: it is what autocomplete and hover documentation are built from. The set is not closed — a rule may also target on attributes the caller supplies in its own evaluation context." + operationId: get-targeting-context + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TargetingContext" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:feature_flags + summary: Get the targeting context schema + tags: + - featureflags + /feature-flags/targeting/lint: + post: + description: "Runs the same check create and update run before accepting a targeting rule, and reports what it found without writing anything. A rule that references a fact or an entitlement that does not exist does not fail loudly at evaluation — it simply never matches — so this is what lets an editor say so while the rule is being written. A rejected rule is a 200 carrying `valid: false`, not a client error." + operationId: lint-targeting-rule + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TargetingRuleDraft" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TargetingRuleVerdict" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:feature_flags + summary: Check a targeting rule + tags: + - featureflags + /feature-flags/targeting/test: + post: + description: "Runs a rule the way an evaluation would, without a flag and without writing anything: the context is enriched from the same sources (license, entitlements, instance, customer, deployment zone), anything the caller forged under __kaiten is discarded the same way, and the same engine answers. The response carries the enriched facts so an author can see exactly what the rule read — which is usually the answer to why it did or did not match. Nothing is metered or audited: this is authoring, not serving." + operationId: test-targeting-rule + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TargetingRuleTrial" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TargetingRuleRehearsal" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:feature_flags + summary: Rehearse a targeting rule against a context + tags: + - featureflags + /feature-flags/{featureFlagSlug}: + delete: + description: Delete a featureflag with the provided details + operationId: delete-feature-flag + parameters: + - in: path + name: featureFlagSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:feature_flags + summary: Delete a featureflag + tags: + - featureflags + get: + description: Get a feature flag with the provided slug + operationId: get-feature-flag + parameters: + - in: path + name: featureFlagSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureFlag" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:feature_flags + summary: Get a feature flag by slug + tags: + - featureflags + put: + description: Update a featureflag with the provided details + operationId: update-feature-flag + parameters: + - in: path + name: featureFlagSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureFlag" + description: Feature flag details + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:feature_flags + summary: Update a featureflag + tags: + - featureflags + /instances: + get: + description: Returns a cursor-paginated page of instances for the current organization. + operationId: getInstances + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageInstance" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: List all instances + tags: + - instances + post: + description: Create a new instance with the provided details + operationId: createInstance + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Instance" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Instance" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:instances + summary: Create a new instance + tags: + - instances + /instances/{instanceSlug}: + delete: + description: Delete an instance with the provided slug + operationId: deleteInstance + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Delete an instance + tags: + - instances + get: + description: Retrieve an instance by their unique slug + operationId: getInstance + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Instance" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Get an instance by slug + tags: + - instances + patch: + description: Partially update an instance. Currently supports the operational status and the commercial lifecycle stage. + operationId: patchInstance + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchInstanceBody" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Patch an instance + tags: + - instances + put: + description: Update an instance with the provided details + operationId: updateInstance + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Instance" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Update an instance + tags: + - instances + /instances/{instanceSlug}/audit-trails: + get: + description: "Returns audit trail events for a given instance, optionally filtered by event name and time range. Results are cursor-paginated (default limit: 50, max: 200) — pass the previous response's nextCursor as the cursor parameter to fetch the next page." + operationId: getAuditTrails + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Filter by exact event name (e.g. ENTITLEMENT_VALUE_GET) + explode: false + in: query + name: event_name + schema: + description: Filter by exact event name (e.g. ENTITLEMENT_VALUE_GET) + type: string + - description: RFC3339 timestamp — include entries with timestamp ≥ this value + explode: false + in: query + name: after + schema: + description: RFC3339 timestamp — include entries with timestamp ≥ this value + type: string + - description: RFC3339 timestamp — include entries with timestamp ≤ this value + explode: false + in: query + name: before + schema: + description: RFC3339 timestamp — include entries with timestamp ≤ this value + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditTrail" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: List audit trail entries for an instance + tags: + - instances + /instances/{instanceSlug}/entitlements/usage: + get: + description: Retrieve a list of all entitlements usage metrics for a specific instance + operationId: getEntitlementsUsageMetrics + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/EntitlementUsage" + type: + - array + - "null" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: List all entitlements usage metrics + tags: + - instances + /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage: + get: + description: Retrieve the usage metrics for a specific entitlement in a given instance + operationId: getEntitlementUsageMetrics + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Entitlement slug + in: path + name: entitlementSlug + required: true + schema: + description: Entitlement slug + examples: + - entitlement-slug + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementUsage" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Get entitlement usage metrics for an instance + tags: + - instances + post: + description: Report a usage metric for a specific entitlement in a given instance. This endpoint allows you to report the usage of an entitlement, including optional metadata and a timestamp. + operationId: reportEntitlementUsageMetric + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Entitlement slug + in: path + name: entitlementSlug + required: true + schema: + description: Entitlement slug + examples: + - entitlement-slug + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ReportEntitlementUsageBody" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementUsage" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Report entitlement usage metric for an instance + tags: + - instances + /instances/{instanceSlug}/integrations/{integrationName}: + delete: + description: Delete a single integration for an instance + operationId: delete-instance-integration + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Delete an instance integration + tags: + - instances + get: + description: Retrieve a single integration for an instance + operationId: get-instance-integration + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Get an instance integration + tags: + - instances + post: + description: Create a single integration for an instance + operationId: create-instance-integration + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: Integration payload + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Create an instance integration + tags: + - instances + put: + description: Update a single integration for an instance + operationId: update-instance-integration + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: Integration payload + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Update an instance integration + tags: + - instances + /integration/{adapter}/customer/{externalId}: + get: + description: Resolve a customer through one of its integrations using adapter + external ID. + operationId: get-customer-integration-by-external-id + parameters: + - description: Integration adapter without the kaiten.integration. prefix + in: path + name: adapter + required: true + schema: + description: Integration adapter without the kaiten.integration. prefix + examples: + - crm.attio + type: string + - description: External identifier in the third-party system + in: path + name: externalId + required: true + schema: + description: External identifier in the third-party system + examples: + - rec_12345 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegrationResource" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:customers + summary: Get a customer by integration external ID + tags: + - integrations + - customers + patch: + description: Create or partially update a customer resolved through adapter + external ID. Missing fields are left unchanged. + operationId: upsert-customer-integration-by-external-id + parameters: + - description: Integration adapter without the kaiten.integration. prefix + in: path + name: adapter + required: true + schema: + description: Integration adapter without the kaiten.integration. prefix + examples: + - crm.attio + type: string + - description: External identifier in the third-party system + in: path + name: externalId + required: true + schema: + description: External identifier in the third-party system + examples: + - rec_12345 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerBody" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerIntegrationResource" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:customers + summary: Upsert a customer by integration external ID + tags: + - integrations + - customers + /integration/{adapter}/instance/{externalId}: + get: + description: Resolve an instance through one of its integrations using adapter + external ID. + operationId: get-instance-integration-by-external-id + parameters: + - description: Integration adapter without the kaiten.integration. prefix + in: path + name: adapter + required: true + schema: + description: Integration adapter without the kaiten.integration. prefix + examples: + - crm.attio + type: string + - description: External identifier in the third-party system + in: path + name: externalId + required: true + schema: + description: External identifier in the third-party system + examples: + - rec_12345 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegrationResource" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Get an instance by integration external ID + tags: + - integrations + - instances + patch: + description: Create or partially update an instance resolved through adapter + external ID. Missing fields are left unchanged. + operationId: upsert-instance-integration-by-external-id + parameters: + - description: Integration adapter without the kaiten.integration. prefix + in: path + name: adapter + required: true + schema: + description: Integration adapter without the kaiten.integration. prefix + examples: + - crm.attio + type: string + - description: External identifier in the third-party system + in: path + name: externalId + required: true + schema: + description: External identifier in the third-party system + examples: + - rec_12345 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceBody" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegrationResource" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:instances + summary: Upsert an instance by integration external ID + tags: + - integrations + - instances + /license-families: + get: + description: "Returns a cursor-paginated page of license families for the current organization, each with the version it currently resolves to: the family's default version, or its highest-numbered published version. A family whose versions are all drafts or all archived is still listed, with no current version." + operationId: list-license-families + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageLicenseFamilyView" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:licenses + summary: List license families + tags: + - license-families + /license-families/{familySlug}: + get: + description: |- + Resolves a license family to the version it currently serves, returned as currentVersion: the family's default version if it has one, otherwise its highest-numbered PUBLISHED version. This is the stable address for "the current version of this product", so a caller never has to name a version slug. + + A family with a default keeps serving it when a newer version is published -- the default is what the vendor puts forward, and superseding it is an explicit act (make another version the default, or unset it). A family without a default follows publication: the newest PUBLISHED version wins. + + Returns 404 GetLicenseFamily.NoPublishedVersion when the family exists but has no PUBLISHED version, since there is nothing to resolve to. Use ?include=versions to read such a family anyway: with the history requested, the family is returned with its versions and no currentVersion. Use ?version=N for explicit access to one version, including draft and archived ones. + operationId: get-license-family + parameters: + - description: License family slug + in: path + name: familySlug + required: true + schema: + description: License family slug + examples: + - enterprise-license + type: string + - description: "Address this version number instead of resolving the current one. Explicit historical access: the version is returned in currentVersion whatever its lifecycle state, draft and archived included, and the resolution rule does not apply." + explode: false + in: query + name: version + schema: + description: "Address this version number instead of resolving the current one. Explicit historical access: the version is returned in currentVersion whatever its lifecycle state, draft and archived included, and the resolution rule does not apply." + format: int32 + minimum: 1 + type: integer + - description: "Extra representations to add, comma-separated. Only 'versions' exists today: it adds every version of the family, oldest first, and makes a family with no PUBLISHED version readable instead of a 404. A list, so that another representation can be added later without changing the parameter's type." + explode: false + in: query + name: include + schema: + description: "Extra representations to add, comma-separated. Only 'versions' exists today: it adds every version of the family, oldest first, and makes a family with no PUBLISHED version readable instead of a 404. A list, so that another representation can be added later without changing the parameter's type." + items: + enum: + - versions + type: string + type: array + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/LicenseFamilyView" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:licenses + summary: Get a license family by slug + tags: + - license-families + /licenses: + get: + description: Returns a cursor-paginated page of licenses for the current organization. + operationId: get-licenses + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageLicense" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:licenses + summary: GetUser all licenses + tags: + - licenses + post: + description: |- + Create a new license with the provided details. Without familySlug or familyId this creates a new license family and the license becomes its version 1. With one of them it adds the next version to that family (familySlug names it by slug, familyId by identifier; sent together they must agree), and the version number is assigned by the server as the next one in the family's sequence. The version starts PUBLISHED, or DRAFT when lifecycleState says so; ARCHIVED is refused (422 CreateLicense.LifecycleStateNotSettable), because a version is archived with archive-license once it has been on sale. + + Where the deployment limits how many licenses an organization may have, only a new family counts against that limit (409 CreateLicense.EntitlementLimitReached once it is reached): a new version of an existing family never does. + operationId: create-license + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/License" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/License" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:licenses + summary: Create a new license + tags: + - licenses + /licenses/{licenseSlug}: + delete: + description: Delete a license with the provided details. Deleting the last version of a license family also deletes the family, and frees its slug for a new product to take. A deleted version's number is not reused by the next version of its family. + operationId: delete-license + parameters: + - description: License slug + in: path + name: licenseSlug + required: true + schema: + description: License slug + examples: + - license-slug + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Delete a license + tags: + - licenses + get: + description: Retrieve a license by their unique slug + operationId: get-license + parameters: + - description: License slug + in: path + name: licenseSlug + required: true + schema: + description: License slug + examples: + - license-slug + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/License" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:licenses + summary: Get a license by slug + tags: + - licenses + put: + description: Update a license with the provided details + operationId: update-license + parameters: + - description: License slug + in: path + name: licenseSlug + required: true + schema: + description: License slug + examples: + - license-slug + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/License" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Update a license + tags: + - licenses + /licenses/{licenseSlug}/archive: + post: + description: "Withdraws a PUBLISHED license version from sale: its family stops resolving to it and no instance can be assigned to it any more, while instances already on it keep it. Only a published version can be archived (409 ArchiveLicense.NotPublished) -- a draft that was never on sale is deleted instead -- and not while it is its family's default (409 ArchiveLicense.DefaultMustBePublished): make another version the default, or unset it, first. Emits LICENSE_ARCHIVED." + operationId: archive-license + parameters: + - description: License version slug + in: path + name: licenseSlug + required: true + schema: + description: License version slug + examples: + - enterprise-license-v2 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/License" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Archive a license version + tags: + - licenses + /licenses/{licenseSlug}/entitlements: + get: + description: Returns a cursor-paginated page of entitlements granted by a license, identified by its unique slug. + operationId: get-license-entitlements + parameters: + - description: License slug + in: path + name: licenseSlug + required: true + schema: + description: License slug + examples: + - license-slug + type: string + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageLicenseEntitlement" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:licenses + summary: Get all entitlements for a license + tags: + - licenses + post: + description: Associate an entitlement with a license by providing the license slug and entitlement details + operationId: associate-entitlement-with-license + parameters: + - description: License slug + in: path + name: licenseSlug + required: true + schema: + description: License slug + examples: + - license-slug + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LicenseEntitlement" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:licenses + summary: Associate an entitlement with a license + tags: + - licenses + /licenses/{licenseSlug}/entitlements/{entitlementSlug}: + delete: + description: Delete a license entitlement with the provided details + operationId: delete-license-entitlement + parameters: + - in: path + name: licenseSlug + required: true + schema: + type: string + - in: path + name: entitlementSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Delete a license entitlement + tags: + - licenses + get: + description: Retrieve a entitlement for a license by their unique slugs + operationId: get-license-entitlement + parameters: + - in: path + name: licenseSlug + required: true + schema: + type: string + - in: path + name: entitlementSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/LicenseEntitlement" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:licenses + summary: Get a license entitlement + tags: + - licenses + put: + description: Update an license entitlement with the provided details + operationId: update-license-entitlement + parameters: + - in: path + name: licenseSlug + required: true + schema: + type: string + - in: path + name: entitlementSlug + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LicenseEntitlement" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Update an license entitlement + tags: + - licenses + /licenses/{licenseSlug}/publish: + post: + description: "Puts a DRAFT license version on sale: it becomes PUBLISHED, so it can be made its family's default, and its family resolves to it when the family has no default and this is its highest published version. Only a draft can be published (409 PublishLicense.NotADraft); an archived version goes back on sale with unarchive-license. Emits LICENSE_PUBLISHED." + operationId: publish-license + parameters: + - description: License version slug + in: path + name: licenseSlug + required: true + schema: + description: License version slug + examples: + - enterprise-license-v2 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/License" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Publish a license version + tags: + - licenses + /licenses/{licenseSlug}/unarchive: + post: + description: "Puts an ARCHIVED license version back on sale: it is PUBLISHED again, can be assigned to instances, and can be what its family resolves to. Only an archived version can be unarchived (409 UnarchiveLicense.NotArchived). Emits LICENSE_UNARCHIVED." + operationId: unarchive-license + parameters: + - description: License version slug + in: path + name: licenseSlug + required: true + schema: + description: License version slug + examples: + - enterprise-license-v2 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/License" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Unarchive a license version + tags: + - licenses + /metadata-fields: + get: + description: Returns a cursor-paginated page of the active (non-archived) metadata fields declared for the org and the given resource type, ordered by displayOrder then createdAt. + operationId: list-metadata-fields + parameters: + - explode: false + in: query + name: resourceType + required: true + schema: + enum: + - DEPLOYMENT_ZONE + - INSTANCE + type: string + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageMetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:metadata_fields + summary: List active metadata fields for a resource type + tags: + - metadataFields + post: + description: Declares a typed metadata field for a given resource type (DEPLOYMENT_ZONE, INSTANCE). The json_schema is validated against JSON Schema 2020-12 before insertion. Restricted to write:metadata_fields scope (admin-only policy). + operationId: create-metadata-field + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:metadata_fields + summary: Create a metadata field + tags: + - metadataFields + /metadata-fields/reorder: + post: + description: Sets the displayOrder of the listed fields to their position in the array (0-indexed). All ids must belong to the same organization, resource type, and be non-archived; the entire reorder is applied in a single transaction. + operationId: reorder-metadata-fields + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ReorderMetadataFieldsInput" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Reorder metadata fields + tags: + - metadataFields + /metadata-fields/{id}: + patch: + description: "Updates label and json_schema. `key` and `resourceType` are immutable, and `displayOrder` belongs to POST /metadata-fields/reorder. The new json_schema is validated against JSON Schema 2020-12 and the transition rules: type changes, cardinality changes, and free-string ↔ enum toggles are rejected with 422." + operationId: update-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Update a metadata field + tags: + - metadataFields + /metadata-fields/{id}/archive: + post: + description: Soft-deletes a metadata field. The row stays in the DB (its archived_at is set) so historic metadata is still interpretable; the field stops appearing in the active schema composition. + operationId: archive-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Archive a metadata field + tags: + - metadataFields + /metadata-fields/{id}/dry-run: + post: + description: Computes, server-side, how many existing resources of this field's resource type carry a value under this field's key that would NOT satisfy the supplied candidate JSON Schema, plus a small sample. Read-only — does not mutate the field. Replaces the former client-side dry-run that pulled every resource's metadata. + operationId: dry-run-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataFieldSchemaProposal" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Impact" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Preview the impact of a metadata field schema change + tags: + - metadataFields + /metadata-fields/{id}/unarchive: + post: + description: Restores a previously archived metadata field (clears its archived_at). The field re-enters the active schema composition. Returns 409 if an active field already uses the same key for this resource type, or 404 if the field does not exist or is not archived. + operationId: unarchive-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Unarchive a metadata field + tags: + - metadataFields + /ofrep/v1/evaluate/flags: + post: + description: |- + OFREP bulk evaluation request. + The endpoint is called by the client providers to evaluate all flags at once. + operationId: evaluateFlagsBulk + parameters: + - description: The request will be processed only if ETag doesn't match any of the values listed. + in: header + name: If-None-Match + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/BulkEvaluationRequest" + description: EvaluateVariant multiple flags in one request + responses: + "200": + content: + application/json: + schema: + oneOf: + - properties: + flags: + description: Array of evaluation results + items: + oneOf: + - $ref: "#/components/schemas/EvaluationSuccess" + - $ref: "#/components/schemas/EvaluationFailure" + type: array + - $ref: "#/components/schemas/BulkEvaluationFailure" + description: OFREP successful evaluation response + headers: + ETag: + description: Entity tag used for cache validation + schema: + type: string + "304": + description: Bulk evaluation is not modified + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/BulkEvaluationFailure" + description: Bad evaluation request + "401": + description: Unauthorized - You need credentials to access the API + "403": + description: Forbidden - You are not authorized to access the API + "429": + description: Rate limit reached on the Flag Management System + headers: + Retry-After: + description: Indicates when to retry the request again + schema: + type: string + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/BulkEvaluationFailure" + description: Internal server error + security: + - bearerAuth: + - read:feature_flags + summary: OFREP bulk flag evaluation contract + tags: + - OFREP Core + /ofrep/v1/evaluate/flags/{key}: + post: + description: EvaluateVariant a single feature flag for a given key + operationId: evaluateFlag + parameters: + - in: path + name: key + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EvaluationRequest" + description: Evaluation context + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/EvaluationSuccess" + description: Flag evaluated successfully + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/EvaluationFailure" + description: Evaluation failed + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/EvaluationFailure" + description: Internal error + security: + - bearerAuth: + - read:feature_flags + summary: OFREP single flag evaluation contract + tags: + - OFREP Core + /openfeature/v0/manifest: + get: + description: Returns a manifest of all feature flags that have a fallback_value set in their metadata + operationId: get-openfeature-manifest + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ManifestEnvelope" + description: OK + headers: + X-Manifest-Capabilities: + schema: + type: string + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:feature_flags + summary: Get OpenFeature manifest + tags: + - OpenFeature + /releases: + get: + description: Returns a cursor-paginated page of releases for the current organization. + operationId: list-releases + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageRelease" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:releases + summary: List releases + tags: + - releases + post: + description: "Create a new release with the provided details and link the requested components. A release is immutable: there is no update endpoint, and the component list cannot change afterwards. Correcting one means deleting it and creating a new one, which starts a new deployment history." + operationId: create-release + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Release" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Release" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:releases + summary: Create a new release + tags: + - releases + /releases/{releaseSlug}: + delete: + description: Delete a release by their slug. Since a release is immutable, this plus a fresh create is the only way to change one -- and it discards the deployment history recorded against the old release. A release still deployed to a zone cannot be deleted (409). + operationId: delete-release + parameters: + - description: Release slug + in: path + name: releaseSlug + required: true + schema: + description: Release slug + examples: + - release-slug + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:releases + summary: Delete a release + tags: + - releases + get: + description: Returns a single release by their slug. + operationId: get-release-by-slug + parameters: + - description: Release slug + in: path + name: releaseSlug + required: true + schema: + description: Release slug + examples: + - release-slug + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Release" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:releases + summary: Get a release by slug + tags: + - deploymentZones + /service-accounts: + get: + description: Returns a cursor-paginated page of service accounts (machine users) for the current organization. + operationId: get-service-accounts + parameters: + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageServiceAccount" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:tokens + summary: List all service accounts + tags: + - service-accounts + post: + description: Create a new service account (machine user) with the provided name. Service accounts can be used to generate API tokens for programmatic access. + operationId: create-service-account + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceAccount" + description: Service account details + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceAccount" + description: Created service account + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:tokens + summary: Create a new service account + tags: + - service-accounts + /service-accounts/{serviceAccountSlug}: + get: + description: Retrieve details of a specific service account (machine user) by its slug. + operationId: get-service-account + parameters: + - description: Service account slug + in: path + name: serviceAccountSlug + required: true + schema: + description: Service account slug + examples: + - service-account-slug + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceAccount" + description: Service account details + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:tokens + summary: Get a service account by slug + tags: + - service-accounts + put: + description: Update the name of a service account (machine user) by its slug. + operationId: update-service-account + parameters: + - description: Service account slug + in: path + name: serviceAccountSlug + required: true + schema: + description: Service account slug + examples: + - service-account-slug + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceAccount" + description: Service account update details + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:tokens + summary: Update a service account + tags: + - service-accounts + /service-accounts/{serviceAccountSlug}/tokens: + get: + description: Returns a cursor-paginated page of API tokens associated with a service account. The organization context is derived from the authenticated user's JWT token. Token secret values are never returned, only metadata. + operationId: get-service-account-tokens + parameters: + - description: Service account slug + in: path + name: serviceAccountSlug + required: true + schema: + description: Service account slug + examples: + - service-account-slug + type: string + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageToken" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:tokens + summary: List all tokens for a service account + tags: + - service-accounts + post: + description: Create a new API token for a service account. The token value is only returned once upon creation and cannot be retrieved again. The organization context is derived from the authenticated user's JWT token. + operationId: create-service-account-token + parameters: + - description: Service account slug + in: path + name: serviceAccountSlug + required: true + schema: + description: Service account slug + examples: + - service-account-slug + format: text + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PlainToken" + description: Token creation details + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/PlainToken" + description: Created token with secret value (only shown once) + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable + security: + - bearerAuth: + - write:tokens + summary: Create a new token for a service account + tags: + - service-accounts + /service-accounts/{serviceAccountSlug}/tokens/{tokenSlug}: + delete: + description: Revoke an API token for a service account. Once revoked, the token can no longer be used for authentication. The organization context is derived from the authenticated user's JWT token. + operationId: delete-service-account-token + parameters: + - description: Service account slug + in: path + name: serviceAccountSlug + required: true + schema: + description: Service account slug + examples: + - service-account-slug + format: text + type: string + - description: Token slug to revoke + in: path + name: tokenSlug + required: true + schema: + description: Token slug to revoke + examples: + - my-token-slug + format: text + type: string + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:tokens + summary: Revoke a token for a service account + tags: + - service-accounts + /v1/notification-preferences: + get: + description: "Every notifiable event, with this user's effective value per channel: their own choice where they made one, the catalogue default otherwise. Events absent from the catalogue of a deployment never appear, which is what keeps high-volume system events out of the feed." + operationId: getNotificationPreferences + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceMatrix" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:notifications + summary: Read the caller's notification preferences + tags: + - notifications + put: + description: Stores the signed-in user's choices and answers with the whole matrix. A value that equals the catalogue default is stored as no row at all, so a later change to that default still reaches this user. + operationId: putNotificationPreferences + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceChoices" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceMatrix" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:notifications + summary: Set the caller's notification preferences + tags: + - notifications + /v1/notifications: + get: + description: Returns the signed-in user's notification feed, newest first, with the unread count. Notifications are a view over this organization's audit trail, narrowed to the events the user subscribes to; the id of a notification is the id of the audit trail entry it reports. + operationId: listNotifications + parameters: + - description: Which notifications to return (default all) + explode: false + in: query + name: status + schema: + description: Which notifications to return (default all) + enum: + - all + - unread + type: string + - description: "Only notifications about these kinds of object; repeat the parameter for several (default: all)" + explode: true + in: query + name: objectType + schema: + description: "Only notifications about these kinds of object; repeat the parameter for several (default: all)" + items: + enum: + - instance + - customer + - release + - deployment_zone + - component + - license + - token + type: string + type: + - array + - "null" + - description: Opaque cursor from a previous response's next_cursor + explode: false + in: query + name: cursor + schema: + description: Opaque cursor from a previous response's next_cursor + type: string + - description: Page size (default 20, max 50) + explode: false + in: query + name: limit + schema: + description: Page size (default 20, max 50) + format: int32 + maximum: 50 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/List" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:notifications + summary: List the caller's notifications + tags: + - notifications + /v1/notifications/mark-read: + post: + description: "Marks the given notifications read for the signed-in user, or all of them with all: true. Ids belonging to another organization are ignored rather than rejected, so a retry of a partially applied call is safe." + operationId: markNotificationsRead + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ReadSelection" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MarkReadResult" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:notifications + summary: Mark notifications read + tags: + - notifications +security: + - bearerAuth: [] +servers: + - url: /api +webhooks: + com.kaiten.component.v1.created: + post: + description: Triggered when a new component is created. + operationId: onComponentCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Component" + name: + description: Event name + enum: + - COMPONENT_CREATED + examples: + - COMPONENT_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.component.v1.created + examples: + - com.kaiten.component.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Component Created Webhook + tags: + - webhooks + - components + com.kaiten.component.v1.deleted: + post: + description: Triggered when a component is deleted. + operationId: onComponentDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Component" + name: + description: Event name + enum: + - COMPONENT_DELETED + examples: + - COMPONENT_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.component.v1.deleted + examples: + - com.kaiten.component.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Component Deleted Webhook + tags: + - webhooks + - components + com.kaiten.component.v1.updated: + post: + description: Triggered when a component is updated or auto-versioned. + operationId: onComponentUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Component" + name: + description: Event name + enum: + - COMPONENT_UPDATED + examples: + - COMPONENT_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.component.v1.updated + examples: + - com.kaiten.component.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Component Updated Webhook + tags: + - webhooks + - components + com.kaiten.customer.v1.created: + post: + description: Triggered when a new customer is created. + operationId: onCustomerCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Customer" + name: + description: Event name + enum: + - CUSTOMER_CREATED + examples: + - CUSTOMER_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.customer.v1.created + examples: + - com.kaiten.customer.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Customer Created Webhook + tags: + - webhooks + - customers + com.kaiten.customer.v1.creation_rejected: + post: + description: Triggered when a customer creation is refused because the organization has reached its customer entitlement cap. No customer is created. + operationId: onCustomerCreationRejected + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/CustomerCreationRejected" + name: + description: Event name + enum: + - CUSTOMER_CREATION_REJECTED + examples: + - CUSTOMER_CREATION_REJECTED + type: string + type: + description: Event type + enum: + - com.kaiten.customer.v1.creation_rejected + examples: + - com.kaiten.customer.v1.creation_rejected + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Customer Creation Rejected Webhook + tags: + - webhooks + - customers + com.kaiten.customer.v1.deleted: + post: + description: Triggered when a customer is deleted. + operationId: onCustomerDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Customer" + name: + description: Event name + enum: + - CUSTOMER_DELETED + examples: + - CUSTOMER_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.customer.v1.deleted + examples: + - com.kaiten.customer.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Customer Deleted Webhook + tags: + - webhooks + - customers + com.kaiten.customer.v1.updated: + post: + description: Triggered when a customer is updated. + operationId: onCustomerUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Customer" + name: + description: Event name + enum: + - CUSTOMER_UPDATED + examples: + - CUSTOMER_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.customer.v1.updated + examples: + - com.kaiten.customer.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Customer Updated Webhook + tags: + - webhooks + - customers + com.kaiten.deployment_zone.v1.created: + post: + description: Triggered when a new deployment zone is created. + operationId: onDeploymentZoneCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/DeploymentZone" + name: + description: Event name + enum: + - DEPLOYMENT_ZONE_CREATED + examples: + - DEPLOYMENT_ZONE_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.deployment_zone.v1.created + examples: + - com.kaiten.deployment_zone.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Deployment Zone Created Webhook + tags: + - webhooks + - deploymentZones + com.kaiten.deployment_zone.v1.deleted: + post: + description: Triggered when a deployment zone is deleted. + operationId: onDeploymentZoneDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/DeploymentZone" + name: + description: Event name + enum: + - DEPLOYMENT_ZONE_DELETED + examples: + - DEPLOYMENT_ZONE_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.deployment_zone.v1.deleted + examples: + - com.kaiten.deployment_zone.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Deployment Zone Deleted Webhook + tags: + - webhooks + - deploymentZones + com.kaiten.deployment_zone.v1.release_deployed: + post: + description: Triggered when a release is deployed to a deployment zone, either at zone creation or when an existing zone's release changes. + operationId: onReleaseDeployed + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Deployment" + name: + description: Event name + enum: + - RELEASE_DEPLOYED + examples: + - RELEASE_DEPLOYED + type: string + type: + description: Event type + enum: + - com.kaiten.deployment_zone.v1.release_deployed + examples: + - com.kaiten.deployment_zone.v1.release_deployed + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Release Deployed Webhook + tags: + - webhooks + - deploymentZones + com.kaiten.deployment_zone.v1.updated: + post: + description: Triggered when a deployment zone is updated. + operationId: onDeploymentZoneUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/DeploymentZone" + name: + description: Event name + enum: + - DEPLOYMENT_ZONE_UPDATED + examples: + - DEPLOYMENT_ZONE_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.deployment_zone.v1.updated + examples: + - com.kaiten.deployment_zone.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Deployment Zone Updated Webhook + tags: + - webhooks + - deploymentZones + com.kaiten.entitlement.v1.created: + post: + description: Triggered when a new entitlement is created. + operationId: onEntitlementCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Entitlement" + name: + description: Event name + enum: + - ENTITLEMENT_CREATED + examples: + - ENTITLEMENT_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.entitlement.v1.created + examples: + - com.kaiten.entitlement.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Created Webhook + tags: + - webhooks + - entitlements + com.kaiten.entitlement.v1.deleted: + post: + description: Triggered when an entitlement is deleted. + operationId: onEntitlementDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Entitlement" + name: + description: Event name + enum: + - ENTITLEMENT_DELETED + examples: + - ENTITLEMENT_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.entitlement.v1.deleted + examples: + - com.kaiten.entitlement.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Deleted Webhook + tags: + - webhooks + - entitlements + com.kaiten.entitlement.v1.updated: + post: + description: Triggered when an entitlement is updated. + operationId: onEntitlementUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Entitlement" + name: + description: Event name + enum: + - ENTITLEMENT_UPDATED + examples: + - ENTITLEMENT_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.entitlement.v1.updated + examples: + - com.kaiten.entitlement.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Updated Webhook + tags: + - webhooks + - entitlements + com.kaiten.entitlement_group.v1.created: + post: + description: Triggered when a new entitlement group is created. + operationId: onEntitlementGroupCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/EntitlementGroup" + name: + description: Event name + enum: + - ENTITLEMENT_GROUP_CREATED + examples: + - ENTITLEMENT_GROUP_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.entitlement_group.v1.created + examples: + - com.kaiten.entitlement_group.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Group Created Webhook + tags: + - webhooks + - entitlement-groups + com.kaiten.entitlement_group.v1.deleted: + post: + description: Triggered when an entitlement group is deleted. + operationId: onEntitlementGroupDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/EntitlementGroup" + name: + description: Event name + enum: + - ENTITLEMENT_GROUP_DELETED + examples: + - ENTITLEMENT_GROUP_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.entitlement_group.v1.deleted + examples: + - com.kaiten.entitlement_group.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Group Deleted Webhook + tags: + - webhooks + - entitlement-groups + com.kaiten.entitlement_group.v1.updated: + post: + description: Triggered when an entitlement group is updated. + operationId: onEntitlementGroupUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/EntitlementGroup" + name: + description: Event name + enum: + - ENTITLEMENT_GROUP_UPDATED + examples: + - ENTITLEMENT_GROUP_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.entitlement_group.v1.updated + examples: + - com.kaiten.entitlement_group.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Group Updated Webhook + tags: + - webhooks + - entitlement-groups + com.kaiten.feature_flag.v1.created: + post: + description: Triggered when a new feature flag is created. + operationId: onFeatureFlagCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/FeatureFlag" + name: + description: Event name + enum: + - FEATURE_FLAG_CREATED + examples: + - FEATURE_FLAG_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.feature_flag.v1.created + examples: + - com.kaiten.feature_flag.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Feature Flag Created Webhook + tags: + - webhooks + - featureflags + com.kaiten.feature_flag.v1.deleted: + post: + description: Triggered when a feature flag is deleted. + operationId: onFeatureFlagDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/FeatureFlag" + name: + description: Event name + enum: + - FEATURE_FLAG_DELETED + examples: + - FEATURE_FLAG_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.feature_flag.v1.deleted + examples: + - com.kaiten.feature_flag.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Feature Flag Deleted Webhook + tags: + - webhooks + - featureflags + com.kaiten.feature_flag.v1.evaluated: + post: + description: Triggered on every flag evaluation. The payload carries no evaluation context, so no targeting key or user attribute is exposed. + operationId: onFeatureFlagEvaluated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/FeatureFlagEvaluated" + name: + description: Event name + enum: + - FEATURE_FLAG_EVALUATED + examples: + - FEATURE_FLAG_EVALUATED + type: string + type: + description: Event type + enum: + - com.kaiten.feature_flag.v1.evaluated + examples: + - com.kaiten.feature_flag.v1.evaluated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Feature Flag Evaluated Webhook + tags: + - webhooks + - featureflags + com.kaiten.feature_flag.v1.updated: + post: + description: Triggered when a feature flag is updated. + operationId: onFeatureFlagUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/FeatureFlag" + name: + description: Event name + enum: + - FEATURE_FLAG_UPDATED + examples: + - FEATURE_FLAG_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.feature_flag.v1.updated + examples: + - com.kaiten.feature_flag.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Feature Flag Updated Webhook + tags: + - webhooks + - featureflags + com.kaiten.identity.v1.system_token_issued: + post: + description: "Triggered when a platform credential mints an organization-scoped credential for the `system:kaiten` platform identity inside this organization. Carries the platform credential that performed the mint and the scopes granted, never the credential value. Expect this at the rate an integration refreshes: roughly 120 per day per process at a 15-minute lifetime." + operationId: onSystemOrganizationTokenIssued + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/SystemTokenIssuance" + name: + description: Event name + enum: + - SYSTEM_ORGANIZATION_TOKEN_ISSUED + examples: + - SYSTEM_ORGANIZATION_TOKEN_ISSUED + type: string + type: + description: Event type + enum: + - com.kaiten.identity.v1.system_token_issued + examples: + - com.kaiten.identity.v1.system_token_issued + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: System Organization Token Issued Webhook + tags: + - webhooks + - identity + com.kaiten.instance.entitlement.v1.cap_exceeded: + post: + description: Triggered when a SOFT-enforcement entitlement's usage crosses from below the cap to above it. Fires once per crossing. + operationId: onInstanceEntitlementCapExceeded + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementCapExceeded" + name: + description: Event name + enum: + - INSTANCE_ENTITLEMENT_CAP_EXCEEDED + examples: + - INSTANCE_ENTITLEMENT_CAP_EXCEEDED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.cap_exceeded + examples: + - com.kaiten.instance.entitlement.v1.cap_exceeded + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Entitlement Cap Exceeded Webhook + tags: + - webhooks + - instances + com.kaiten.instance.entitlement.v1.usage_period_rolled_over: + post: + description: "Triggered once per usage window closed by a report: the entitlement's own stored bucket, and one synthetic event per window skipped entirely since the last report." + operationId: onInstanceEntitlementUsagePeriodRolledOver + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementUsagePeriodRolledOver" + name: + description: Event name + enum: + - INSTANCE_ENTITLEMENT_USAGE_PERIOD_ROLLED_OVER + examples: + - INSTANCE_ENTITLEMENT_USAGE_PERIOD_ROLLED_OVER + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.usage_period_rolled_over + examples: + - com.kaiten.instance.entitlement.v1.usage_period_rolled_over + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Usage Period Rolled Over Webhook + tags: + - webhooks + - instances + com.kaiten.instance.entitlement.v1.usage_reached: + post: + description: Triggered when an instance's entitlement usage reaches its threshold. + operationId: onInstanceEntitlementUsageReached + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/EntitlementUsage" + name: + description: Event name + enum: + - INSTANCE_ENTITLEMENT_USAGE_REACHED + examples: + - INSTANCE_ENTITLEMENT_USAGE_REACHED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.usage_reached + examples: + - com.kaiten.instance.entitlement.v1.usage_reached + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Entitlement Usage Reached Webhook + tags: + - webhooks + - instances + com.kaiten.instance.entitlement.v1.usage_report_accepted: + post: + description: Triggered when a usage report is persisted, including a SOFT-enforcement overage. + operationId: onInstanceEntitlementUsageReportAccepted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementUsageReportAccepted" + name: + description: Event name + enum: + - ENTITLEMENT_USAGE_REPORT_ACCEPTED + examples: + - ENTITLEMENT_USAGE_REPORT_ACCEPTED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.usage_report_accepted + examples: + - com.kaiten.instance.entitlement.v1.usage_report_accepted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Usage Report Accepted Webhook + tags: + - webhooks + - instances + com.kaiten.instance.entitlement.v1.usage_report_rejected: + post: + description: Triggered when a usage report is refused because it would take a HARD-enforcement entitlement past its cap. The usage is not persisted. + operationId: onInstanceEntitlementUsageReportRejected + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementUsageReportAccepted" + name: + description: Event name + enum: + - ENTITLEMENT_USAGE_REPORT_REJECTED + examples: + - ENTITLEMENT_USAGE_REPORT_REJECTED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.usage_report_rejected + examples: + - com.kaiten.instance.entitlement.v1.usage_report_rejected + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Entitlement Usage Report Rejected Webhook + tags: + - webhooks + - instances + com.kaiten.instance.entitlement.v1.usage_warning_threshold_reached: + post: + description: Triggered when an instance's entitlement usage crosses its configured early-warning percentage boundary, before the cap is reached. Fires once per crossing. + operationId: onInstanceEntitlementUsageWarningThresholdReached + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementUsageWarningThresholdReached" + name: + description: Event name + enum: + - INSTANCE_ENTITLEMENT_USAGE_WARNING_THRESHOLD_REACHED + examples: + - INSTANCE_ENTITLEMENT_USAGE_WARNING_THRESHOLD_REACHED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.usage_warning_threshold_reached + examples: + - com.kaiten.instance.entitlement.v1.usage_warning_threshold_reached + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Entitlement Usage Warning Threshold Reached Webhook + tags: + - webhooks + - instances + com.kaiten.instance.entitlement.v1.value_get: + post: + description: Triggered on every read of an instance entitlement's usage value. This is a read audit signal, not a state change. + operationId: onInstanceEntitlementValueGet + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementValueGet" + name: + description: Event name + enum: + - ENTITLEMENT_VALUE_GET + examples: + - ENTITLEMENT_VALUE_GET + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.value_get + examples: + - com.kaiten.instance.entitlement.v1.value_get + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Entitlement Value Read Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.created: + post: + description: Triggered when a new instance is created. + operationId: onInstanceCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Instance" + name: + description: Event name + enum: + - INSTANCE_CREATED + examples: + - INSTANCE_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.created + examples: + - com.kaiten.instance.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Created Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.deleted: + post: + description: Triggered when an instance is deleted. + operationId: onInstanceDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Instance" + name: + description: Event name + enum: + - INSTANCE_DELETED + examples: + - INSTANCE_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.deleted + examples: + - com.kaiten.instance.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Deleted Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.deployed: + post: + description: Triggered when an instance is deployed to a deployment zone. + operationId: onInstanceDeployed + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Instance" + name: + description: Event name + enum: + - INSTANCE_DEPLOYED + examples: + - INSTANCE_DEPLOYED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.deployed + examples: + - com.kaiten.instance.v1.deployed + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Deployed Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.lifecycle_stage_changed: + post: + description: Triggered when an instance commercial lifecycle stage changes. + operationId: onInstanceLifecycleStageChanged + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceLifecycleStageChanged" + name: + description: Event name + enum: + - INSTANCE_LIFECYCLE_STAGE_CHANGED + examples: + - INSTANCE_LIFECYCLE_STAGE_CHANGED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.lifecycle_stage_changed + examples: + - com.kaiten.instance.v1.lifecycle_stage_changed + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Lifecycle Stage Changed Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.migrated: + post: + description: Triggered when an instance is migrated from one deployment zone to another. + operationId: onInstanceMigrated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Instance" + name: + description: Event name + enum: + - INSTANCE_MIGRATED + examples: + - INSTANCE_MIGRATED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.migrated + examples: + - com.kaiten.instance.v1.migrated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Migrated Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.status_changed: + post: + description: Triggered when an instance operational status changes. + operationId: onInstanceStatusChanged + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceStatusChanged" + name: + description: Event name + enum: + - INSTANCE_STATUS_CHANGED + examples: + - INSTANCE_STATUS_CHANGED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.status_changed + examples: + - com.kaiten.instance.v1.status_changed + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Status Changed Webhook + tags: + - webhooks + - instances + com.kaiten.instance.v1.updated: + post: + description: Triggered when an instance is updated. + operationId: onInstanceUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Instance" + name: + description: Event name + enum: + - INSTANCE_UPDATED + examples: + - INSTANCE_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.v1.updated + examples: + - com.kaiten.instance.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance Updated Webhook + tags: + - webhooks + - instances + com.kaiten.license.entitlement.v1.assigned: + post: + description: Triggered when an entitlement is assigned to a license. + operationId: onLicenseEntitlementAssigned + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/LicenseEntitlement" + name: + description: Event name + enum: + - LICENSE_ENTITLEMENT_ASSIGNED + examples: + - LICENSE_ENTITLEMENT_ASSIGNED + type: string + type: + description: Event type + enum: + - com.kaiten.license.entitlement.v1.assigned + examples: + - com.kaiten.license.entitlement.v1.assigned + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Entitlement Assigned Webhook + tags: + - webhooks + - licenses + com.kaiten.license.entitlement.v1.unassigned: + post: + description: Triggered when an entitlement is unassigned from a license. + operationId: onLicenseEntitlementUnassigned + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/LicenseEntitlement" + name: + description: Event name + enum: + - LICENSE_ENTITLEMENT_UNASSIGNED + examples: + - LICENSE_ENTITLEMENT_UNASSIGNED + type: string + type: + description: Event type + enum: + - com.kaiten.license.entitlement.v1.unassigned + examples: + - com.kaiten.license.entitlement.v1.unassigned + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Entitlement Unassigned Webhook + tags: + - webhooks + - licenses + com.kaiten.license.entitlement.v1.updated: + post: + description: Triggered when a license entitlement is updated. + operationId: onLicenseEntitlementUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/LicenseEntitlement" + name: + description: Event name + enum: + - LICENSE_ENTITLEMENT_UPDATED + examples: + - LICENSE_ENTITLEMENT_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.license.entitlement.v1.updated + examples: + - com.kaiten.license.entitlement.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Entitlement Updated Webhook + tags: + - webhooks + - licenses + com.kaiten.license.v1.archived: + post: + description: Triggered when a published license version is archived and withdrawn from sale. + operationId: onLicenseArchived + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/License" + name: + description: Event name + enum: + - LICENSE_ARCHIVED + examples: + - LICENSE_ARCHIVED + type: string + type: + description: Event type + enum: + - com.kaiten.license.v1.archived + examples: + - com.kaiten.license.v1.archived + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Archived Webhook + tags: + - webhooks + - licenses + com.kaiten.license.v1.created: + post: + description: Triggered when a new license is created. + operationId: onLicenseCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/License" + name: + description: Event name + enum: + - LICENSE_CREATED + examples: + - LICENSE_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.license.v1.created + examples: + - com.kaiten.license.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Created Webhook + tags: + - webhooks + - licenses + com.kaiten.license.v1.deleted: + post: + description: Triggered when a license is deleted. + operationId: onLicenseDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/License" + name: + description: Event name + enum: + - LICENSE_DELETED + examples: + - LICENSE_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.license.v1.deleted + examples: + - com.kaiten.license.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Deleted Webhook + tags: + - webhooks + - licenses + com.kaiten.license.v1.published: + post: + description: Triggered when a draft license version is published and goes on sale. + operationId: onLicensePublished + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/License" + name: + description: Event name + enum: + - LICENSE_PUBLISHED + examples: + - LICENSE_PUBLISHED + type: string + type: + description: Event type + enum: + - com.kaiten.license.v1.published + examples: + - com.kaiten.license.v1.published + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Published Webhook + tags: + - webhooks + - licenses + com.kaiten.license.v1.unarchived: + post: + description: Triggered when an archived license version is put back on sale. + operationId: onLicenseUnarchived + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/License" + name: + description: Event name + enum: + - LICENSE_UNARCHIVED + examples: + - LICENSE_UNARCHIVED + type: string + type: + description: Event type + enum: + - com.kaiten.license.v1.unarchived + examples: + - com.kaiten.license.v1.unarchived + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Unarchived Webhook + tags: + - webhooks + - licenses + com.kaiten.license.v1.updated: + post: + description: Triggered when a license is updated. + operationId: onLicenseUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/License" + name: + description: Event name + enum: + - LICENSE_UPDATED + examples: + - LICENSE_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.license.v1.updated + examples: + - com.kaiten.license.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Updated Webhook + tags: + - webhooks + - licenses + com.kaiten.license_family.v1.created: + post: + description: "Triggered when a license is created without naming a family: its version 1 opens a new family, which this event carries, with the version it serves -- none if that version is a draft. It is recorded in the same transaction as that version's LICENSE_CREATED." + operationId: onLicenseFamilyCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/LicenseFamilyView" + name: + description: Event name + enum: + - LICENSE_FAMILY_CREATED + examples: + - LICENSE_FAMILY_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.license_family.v1.created + examples: + - com.kaiten.license_family.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Family Created Webhook + tags: + - webhooks + - license-families + com.kaiten.license_family.v1.deleted: + post: + description: Triggered when a license family's last version is deleted, which deletes the family and frees its slug. It carries the family as it stood before that version went, and is recorded in the same transaction as that version's LICENSE_DELETED. + operationId: onLicenseFamilyDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/LicenseFamilyView" + name: + description: Event name + enum: + - LICENSE_FAMILY_DELETED + examples: + - LICENSE_FAMILY_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.license_family.v1.deleted + examples: + - com.kaiten.license_family.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Family Deleted Webhook + tags: + - webhooks + - license-families + com.kaiten.license_family.v1.updated: + post: + description: "Triggered when a license family starts serving another version, or none: a newer version published, a version published, archived or unarchived, the family's default taken or given up, or the served version deleted. It carries the family with the version it serves now, and is recorded in the same transaction as the version event that caused the change. A change that leaves the served version where it was -- a draft added, a version renamed, a default restated -- triggers none." + operationId: onLicenseFamilyUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/LicenseFamilyView" + name: + description: Event name + enum: + - LICENSE_FAMILY_UPDATED + examples: + - LICENSE_FAMILY_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.license_family.v1.updated + examples: + - com.kaiten.license_family.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: License Family Updated Webhook + tags: + - webhooks + - license-families + com.kaiten.metadata_field.v1.archived: + post: + description: Triggered when a metadata field is archived and leaves the active schema composition. + operationId: onMetadataFieldArchived + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MetadataField" + name: + description: Event name + enum: + - METADATA_FIELD_ARCHIVED + examples: + - METADATA_FIELD_ARCHIVED + type: string + type: + description: Event type + enum: + - com.kaiten.metadata_field.v1.archived + examples: + - com.kaiten.metadata_field.v1.archived + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Metadata Field Archived Webhook + tags: + - webhooks + - metadataFields + com.kaiten.metadata_field.v1.created: + post: + description: Triggered when a metadata field is declared for a resource type. + operationId: onMetadataFieldCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MetadataField" + name: + description: Event name + enum: + - METADATA_FIELD_CREATED + examples: + - METADATA_FIELD_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.metadata_field.v1.created + examples: + - com.kaiten.metadata_field.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Metadata Field Created Webhook + tags: + - webhooks + - metadataFields + com.kaiten.metadata_field.v1.reordered: + post: + description: Triggered when the display order of a resource type's metadata fields is rewritten. + operationId: onMetadataFieldReordered + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MetadataFieldReordered" + name: + description: Event name + enum: + - METADATA_FIELD_REORDERED + examples: + - METADATA_FIELD_REORDERED + type: string + type: + description: Event type + enum: + - com.kaiten.metadata_field.v1.reordered + examples: + - com.kaiten.metadata_field.v1.reordered + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Metadata Fields Reordered Webhook + tags: + - webhooks + - metadataFields + com.kaiten.metadata_field.v1.unarchived: + post: + description: Triggered when an archived metadata field is restored to the active schema composition. + operationId: onMetadataFieldUnarchived + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MetadataField" + name: + description: Event name + enum: + - METADATA_FIELD_UNARCHIVED + examples: + - METADATA_FIELD_UNARCHIVED + type: string + type: + description: Event type + enum: + - com.kaiten.metadata_field.v1.unarchived + examples: + - com.kaiten.metadata_field.v1.unarchived + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Metadata Field Unarchived Webhook + tags: + - webhooks + - metadataFields + com.kaiten.metadata_field.v1.updated: + post: + description: Triggered when a metadata field's label or JSON schema changes. A reordering emits METADATA_FIELD_REORDERED instead. + operationId: onMetadataFieldUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MetadataField" + name: + description: Event name + enum: + - METADATA_FIELD_UPDATED + examples: + - METADATA_FIELD_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.metadata_field.v1.updated + examples: + - com.kaiten.metadata_field.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Metadata Field Updated Webhook + tags: + - webhooks + - metadataFields + com.kaiten.release.v1.created: + post: + description: Triggered when a new release is created. + operationId: onReleaseCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Release" + name: + description: Event name + enum: + - RELEASE_CREATED + examples: + - RELEASE_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.release.v1.created + examples: + - com.kaiten.release.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Release Created Webhook + tags: + - webhooks + - releases + com.kaiten.release.v1.deleted: + post: + description: Triggered when a release is deleted. + operationId: onReleaseDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Release" + name: + description: Event name + enum: + - RELEASE_DELETED + examples: + - RELEASE_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.release.v1.deleted + examples: + - com.kaiten.release.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Release Deleted Webhook + tags: + - webhooks + - releases diff --git a/openapi/platform-openapi.yaml b/openapi/platform-openapi.yaml new file mode 100644 index 0000000..38b05bc --- /dev/null +++ b/openapi/platform-openapi.yaml @@ -0,0 +1,928 @@ +components: + schemas: + Connector: + additionalProperties: false + properties: + created_at: + description: Timestamp when connector was first registered + format: date-time + type: string + entitlement_slug: + description: Slug of the BOOLEAN entitlement a license must grant for an organization to activate this connector. Absent means the connector is ungated. + examples: + - connector-attio + type: string + name: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + type: string + settings_schema: + additionalProperties: {} + description: JSON schema used to validate connector settings payloads + type: object + updated_at: + description: Timestamp when connector registration was last updated + format: date-time + type: string + version: + description: Registered connector version + examples: + - 1.0.0 + type: string + required: + - name + - version + - settings_schema + - created_at + - updated_at + type: object + EnsureOrganizationBody: + additionalProperties: false + properties: + externalId: + description: The identity provider's id for the organization. The row's id is derived from this value, so the same external id always converges on the same organization. + examples: + - org_2abcDEF + maxLength: 255 + minLength: 1 + type: string + name: + description: Display name for a newly created organization. Never replaces an existing one's name; omitted, a new organization is named after its external id. + examples: + - Kaiten + maxLength: 255 + type: string + required: + - externalId + type: object + ErrorDetail: + additionalProperties: false + properties: + location: + description: Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + type: string + message: + description: Error message text + type: string + value: + description: The value at the given location + type: object + MintOrganizationTokenBody: + additionalProperties: false + properties: + name: + description: Human-readable label for the credential + examples: + - ci-deploy + maxLength: 255 + minLength: 1 + type: string + scopes: + description: Requested scopes, which must be a subset of the calling platform credential's. Omitted inherits all of them. + examples: + - - read:customers + items: + type: string + type: + - array + - "null" + ttl: + description: Lifetime as a Go duration ("15m", "24h"). Omitted mints a non-expiring credential, whose bound is revocation -- including the cascade from the platform credential that minted it. + examples: + - 15m + type: string + required: + - name + type: object + Organization: + additionalProperties: false + properties: + external_id: + description: External identifier for the organization, used for integration with external systems + examples: + - ext-123e4567-e89b-12d3-a456-426614174000 + type: string + id: + description: Unique identifier for the organization + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + name: + description: Display name of the organization + examples: + - My Organization + type: string + required: + - id + - external_id + - name + type: object + PlainToken: + additionalProperties: false + properties: + createdAt: + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + readOnly: true + expiresAt: + format: date-time + type: string + id: + readOnly: true + type: string + name: + type: string + revokedAt: + format: date-time + readOnly: true + type: string + revokedBy: + $ref: "#/components/schemas/User" + readOnly: true + scopes: + items: + type: string + type: + - array + - "null" + serviceAccountId: + readOnly: true + type: string + slug: + description: Optional URL-friendly identifier, unique per service account. Auto-generated if not provided. + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + token: + readOnly: true + type: string + required: + - name + - scopes + - createdAt + - createdBy + type: object + PlatformCredential: + additionalProperties: false + properties: + createdAt: + format: date-time + readOnly: true + type: string + credentialKind: + description: Credential class of the calling token + examples: + - platform + type: string + expiresAt: + description: Absent when the credential does not expire + format: date-time + type: string + id: + readOnly: true + type: string + name: + type: string + scopes: + items: + type: string + type: + - array + - "null" + slug: + type: string + subject: + description: External id of the platform identity this credential authenticates + examples: + - system:kaiten + type: string + required: + - id + - name + - slug + - subject + - credentialKind + - scopes + - createdAt + type: object + Problem: + additionalProperties: false + properties: + code: + description: Stable, machine-readable error code. + examples: + - License.NotFound + type: string + detail: + description: A human-readable explanation specific to this occurrence of the problem. + examples: + - license not found + type: string + errorId: + description: Correlation id for the server-side log entry describing the withheld cause. + examples: + - 3f6b1a2c-7c1e-4d0b-9d5f-2b0a1c4e8d31 + type: string + errors: + description: Optional list of individual error details + items: + $ref: "#/components/schemas/ErrorDetail" + type: + - array + - "null" + instance: + description: A URI reference that identifies the specific occurrence of the problem. + examples: + - /api/licenses/enterprise + type: string + status: + description: HTTP status code + examples: + - 404 + format: int64 + type: integer + title: + description: A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + examples: + - Not Found + type: string + type: + default: about:blank + description: A URI reference to human-readable documentation for the error. + examples: + - https://tools.ietf.org/html/rfc7231#section-6.5.4 + format: uri + type: string + type: object + RegisterConnectorBody: + additionalProperties: false + properties: + entitlement_slug: + description: Slug of the BOOLEAN entitlement a license must grant for an organization to activate this connector. Absent means the connector is ungated. + examples: + - connector-attio + type: string + name: + description: Stable connector identifier + examples: + - kaiten.integration.crm.attio + type: string + settings_schema: + additionalProperties: {} + description: JSON schema used to validate organization-level connector settings + type: object + version: + description: Connector version + examples: + - 1.0.0 + type: string + required: + - name + - version + - settings_schema + type: object + Token: + additionalProperties: false + properties: + createdAt: + format: date-time + readOnly: true + type: string + createdBy: + $ref: "#/components/schemas/User" + readOnly: true + expiresAt: + format: date-time + type: string + id: + readOnly: true + type: string + name: + type: string + revokedAt: + format: date-time + readOnly: true + type: string + revokedBy: + $ref: "#/components/schemas/User" + readOnly: true + scopes: + items: + type: string + type: + - array + - "null" + serviceAccountId: + readOnly: true + type: string + slug: + description: Optional URL-friendly identifier, unique per service account. Auto-generated if not provided. + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + required: + - name + - scopes + - createdAt + - createdBy + type: object + User: + additionalProperties: false + properties: + id: + description: ID of the user + examples: + - 123e4567-e89b-12d3-a456-426614174000 + type: string + name: + description: Name of the user + examples: + - John Doe + type: string + required: + - id + type: object + securitySchemes: + platformAuth: + bearerFormat: Platform token + description: A Kaiten platform token (`ksm_...`), which authenticates the platform identity `system:kaiten` and carries no organization context. Organization credentials — JWTs from the configured OIDC issuer and `ksh_...` access tokens — are rejected on this surface, and a platform token is rejected on the Core API. The scopes listed on each operation are the ones the token must carry. + scheme: bearer + type: http +info: + contact: + email: support@kaiten.sh + name: Kaiten Team + description: Platform-wide operations, authenticated by a Kaiten platform token (`ksm_...`) that identifies `system:kaiten` and carries no organization execution context. An operation that acts inside an organization names it in the path; it never inherits one from the credential. + title: Kaiten Platform API + version: 1.0.0 +openapi: 3.1.0 +paths: + /platform/connectors: + post: + description: Registers or updates connector metadata and settings schema for this Kaiten deployment + operationId: register-connector + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterConnectorBody" + description: Connector registration payload containing metadata and settings schema + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Connector" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - write:organizations + summary: Register connector manifest + tags: + - connectors + /platform/me: + get: + description: Returns the name, scopes, expiry and creation time of the platform credential used to make the request, plus the platform identity it authenticates. The token value is never returned -- it is shown once, at creation. + operationId: get-platform-me + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PlatformCredential" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - read:tokens + summary: Describe the calling platform credential + tags: + - platform + /platform/organizations: + post: + description: "Converges on the organization an external identity provider describes, creating it if it is not already there and returning it either way. The internal id is derived from the external id, so the operation is idempotent: a re-run returns the same organization instead of creating a second tenant, and an organization that already exists -- including one provisioned on a first login -- is returned unchanged apart from acquiring a name if it had none. Platform API: requires a Kaiten platform token (`ksm_...`) with write:organizations." + operationId: ensure-organization + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EnsureOrganizationBody" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - write:organizations + summary: Create an organization if it does not already exist + tags: + - organizations + /platform/organizations/{orgId}: + delete: + description: "Permanently deletes an organization and everything it owns — customers, licenses, feature flags, entitlements, instances, releases, memberships, service accounts and their tokens, and its audit trail. This cannot be undone, and the organization's usage history cannot be rebuilt. Platform API: requires a Kaiten platform token (`ksm_...`) with delete:organizations." + operationId: delete-organization + parameters: + - description: Organization ID + in: path + name: orgId + required: true + schema: + description: Organization ID + format: uuid + type: string + responses: + "204": + description: No Content + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - delete:organizations + summary: Delete an organization + tags: + - organizations + get: + description: "Retrieve an organization by their unique identifier. Platform API: requires a Kaiten platform token (`ksm_...`)." + operationId: get-organization + parameters: + - description: Organization ID + in: path + name: orgId + required: true + schema: + description: Organization ID + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - read:organizations + summary: Get an organization by ID + tags: + - organizations + /platform/organizations/{orgId}/memberships/{userId}: + delete: + description: "Soft-deletes a single membership. Platform API: requires a Kaiten platform token (`ksm_...`) with delete:memberships. The system:kaiten membership cannot be removed — the database refuses it, and this answers 403." + operationId: delete-membership + parameters: + - description: Organization ID + in: path + name: orgId + required: true + schema: + description: Organization ID + format: uuid + type: string + - description: User ID + in: path + name: userId + required: true + schema: + description: User ID + format: uuid + type: string + responses: + "204": + description: No Content + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - delete:memberships + summary: Remove a user's membership on an organization + tags: + - organizations + /platform/organizations/{orgId}/tokens: + get: + description: "Names the still-active credentials this platform credential minted in the named organization, so one can be addressed for revocation: revoke-organization-token takes a slug, and a slug is server-generated and cannot be derived from the name the caller chose. Only credentials issued by the calling platform credential, in the named organization, are listed. No credential value is returned -- these identify, they do not authenticate." + operationId: list-organization-tokens + parameters: + - description: Organization the credential acts inside + in: path + name: orgId + required: true + schema: + description: Organization the credential acts inside + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + description: Active credentials this platform credential holds in the named organization + items: + $ref: "#/components/schemas/Token" + type: + - array + - "null" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - read:tokens + summary: List the organization credentials minted for the platform identity + tags: + - platform + post: + description: Issues an ordinary organization-scoped access token (`ksh_...`) owned by the system:kaiten platform identity inside the named organization, derived from its existing membership there. Requested scopes must be a subset of the calling platform credential's; omitting them inherits all of them. TTL is optional -- omitted mints a non-expiring credential. The token value is returned once, here, and can never be retrieved again. Revoking the platform credential that minted it also revokes this one. + operationId: mint-organization-token + parameters: + - description: Organization to mint the credential inside + in: path + name: orgId + required: true + schema: + description: Organization to mint the credential inside + format: uuid + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MintOrganizationTokenBody" + description: Credential details + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/PlainToken" + description: The minted credential, including its secret value (shown only once) + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - write:tokens + summary: Mint an organization credential for the platform identity + tags: + - platform + /platform/organizations/{orgId}/tokens/{tokenSlug}: + delete: + description: Retires a credential this platform credential minted in the named organization, before the platform credential itself is retired. Only credentials issued by the calling platform credential, in the named organization, can be revoked here. + operationId: revoke-organization-token + parameters: + - description: Organization the credential acts inside + in: path + name: orgId + required: true + schema: + description: Organization the credential acts inside + format: uuid + type: string + - description: Slug returned when the credential was minted + in: path + name: tokenSlug + required: true + schema: + description: Slug returned when the credential was minted + examples: + - system-kaiten-a1b2c3 + format: text + type: string + responses: + "204": + description: No Content + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - write:tokens + summary: Revoke an organization credential minted for the platform identity + tags: + - platform + /platform/users/{id}: + delete: + description: "Soft-deletes a user by ID. Platform API: requires a Kaiten platform token (`ksm_...`) with delete:users. The system:kaiten platform identity cannot be deleted — the database refuses it, and this answers 403." + operationId: delete-user + parameters: + - description: User ID + in: path + name: id + required: true + schema: + description: User ID + format: uuid + type: string + responses: + "204": + description: No Content + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - platformAuth: + - delete:users + summary: Delete a user + tags: + - users +security: + - platformAuth: [] +servers: + - url: /api diff --git a/openapi/source.yaml b/openapi/source.yaml new file mode 100644 index 0000000..3f7c144 --- /dev/null +++ b/openapi/source.yaml @@ -0,0 +1,9 @@ +# Where the two snapshots in this directory were copied from. Rewritten by +# `task sync:openapi`; never edit the snapshots themselves by hand. +repository: kaitencloud/kaiten +ref: origin/main +commit: fa7f554fb6464f52c2a3f1ed487f4c7383c8125d +synced_at: "2026-09-30" +files: + openapi.yaml: app/openapi.yaml + platform-openapi.yaml: app/platform-openapi.yaml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..546acca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,123 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "kaitencloud" +dynamic = ["version"] +description = "Official Python SDK for Kaiten: customers, instances, licenses, entitlements, usage metering, feature flags and the Platform API." +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE"] +authors = [{ name = "KAITEN INC", email = "support@kaiten.sh" }] +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27,<1", + "pydantic>=2.11,<3", + "typing-extensions>=4.12.2", +] +keywords = [ + "kaiten", + "saas", + "entitlements", + "licensing", + "usage-metering", + "feature-flags", + "openfeature", + "sdk", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://kaiten.sh" +Documentation = "https://docs.kaiten.sh" +Repository = "https://github.com/kaitencloud/sdk-python" +Issues = "https://github.com/kaitencloud/sdk-python/issues" +Changelog = "https://github.com/kaitencloud/sdk-python/blob/main/CHANGELOG.md" + +[dependency-groups] +dev = [ + "anyio>=4.6", + "jsonschema>=4.23", + "mypy>=2.3", + "openfeature-provider-ofrep>=0.3.0", + "pytest>=9.0", + "pytest-cov>=7.0", + "pyyaml>=6.0", + "ruff>=0.16", + "types-jsonschema>=4.23", + "types-pyyaml>=6.0", +] + +[tool.hatch.version] +path = "src/kaitencloud/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/kaitencloud"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/examples", + "/tests", + "/openapi", + "/scripts", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", + "/NOTICE", +] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["B", "C4", "E", "F", "I", "N", "PIE", "PT", "RET", "RUF", "SIM", "UP", "W"] +ignore = [ + # Docstrings and contract descriptions run long; the formatter owns code width. + "E501", +] + +[tool.ruff.lint.isort] +known-first-party = ["kaitencloud"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unreachable = true +files = ["src/kaitencloud", "examples"] +plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_typed = true +warn_required_dynamic_aliases = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers", "--strict-config"] +xfail_strict = true +filterwarnings = ["error"] + +[tool.coverage.run] +branch = true +source = ["kaitencloud"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_also = ["if TYPE_CHECKING:", "@overload"] diff --git a/scripts/_codegen.py b/scripts/_codegen.py new file mode 100644 index 0000000..4460128 --- /dev/null +++ b/scripts/_codegen.py @@ -0,0 +1,83 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Shared plumbing for the code generators: format like the project, then write or compare. + +Both generators (``generate_models.py`` and ``unasync.py``) produce committed files. They +render into a scratch directory, run ruff over it with the project's own configuration, and +only then compare with -- or copy over -- what is on disk. Formatting before comparing is +what lets ``--check`` be exact: a stale file is a real difference, never a whitespace one. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Iterable, Mapping +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +LICENSE_HEADER = "# Copyright 2026 KAITEN INC\n# SPDX-License-Identifier: Apache-2.0\n" +"""The notice every Python file of the project opens with, generated ones included.""" + + +def emit(files: Mapping[Path, str], *, check: bool, orphans: Iterable[Path] = ()) -> bool: + """Write ``files`` (absolute path -> content), or report the ones that are stale. + + ``orphans`` are generated files that no longer have a source; they are deleted, or + reported under ``check``. Returns ``True`` when the tree already matched. + """ + with tempfile.TemporaryDirectory() as scratch: + staged: dict[Path, Path] = {} + for target, content in sorted(files.items()): + path = Path(scratch) / target.relative_to(ROOT) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + staged[target] = path + + if staged: + _ruff("check", "--select", "I,F401,RUF022", "--fix", "--exit-zero", *staged.values()) + _ruff("format", *staged.values()) + + stale = [ + target + for target, path in staged.items() + if not target.exists() + or target.read_text(encoding="utf-8") != path.read_text(encoding="utf-8") + ] + leftovers = sorted(path for path in orphans if path.exists()) + + if check: + for path in stale: + print(f"stale: {path.relative_to(ROOT)}", file=sys.stderr) + for path in leftovers: + print(f"orphaned: {path.relative_to(ROOT)}", file=sys.stderr) + return not stale and not leftovers + + for target in stale: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(staged[target], target) + print(f"wrote {target.relative_to(ROOT)}") + for path in leftovers: + path.unlink() + print(f"removed {path.relative_to(ROOT)}") + return not stale and not leftovers + + +def _ruff(command: str, *args: object) -> None: + subprocess.run( + [ + sys.executable, + "-m", + "ruff", + command, + "--config", + str(ROOT / "pyproject.toml"), + "--quiet", + *(str(arg) for arg in args), + ], + check=True, + ) diff --git a/scripts/generate_models.py b/scripts/generate_models.py new file mode 100644 index 0000000..1ea111c --- /dev/null +++ b/scripts/generate_models.py @@ -0,0 +1,678 @@ +#!/usr/bin/env python3 +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Generate kaitencloud's pydantic models from the committed OpenAPI snapshots. + +The SDK's models are never written by hand. They are rendered from ``openapi/openapi.yaml`` +and ``openapi/platform-openapi.yaml`` -- the contract -- so a model can only drift from the +API by the snapshot drifting, which ``task sync:openapi`` fixes and CI catches. + +Usage:: + + uv run python scripts/generate_models.py # rewrite src/kaitencloud/types/ + uv run python scripts/generate_models.py --check # exit 1 when the committed files are stale + +Three rules shape the output, each because the alternative breaks a caller: + +- Only what the SDK can return is generated: the response schemas of the operations + ``openapi/coverage.yaml`` maps, and the payloads of the published webhooks. Request bodies + are built by the resource methods, and page envelopes are walked, never returned. +- Models are lenient where the contract grows. An enum with several values is typed + ``Known | str``, so a value added to the API after a release parses instead of failing + every read that carries it. Single-value enums stay exact: they are union tags. +- Nothing is guessed. A new multi-value enum or inline union must be named below, and an + attribute that would shadow a pydantic member must be renamed below; the generator stops + until it is. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import keyword +import re +import sys +import textwrap +from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import Any + +import yaml +from _codegen import LICENSE_HEADER, ROOT, emit +from pydantic import BaseModel + +CORE_SPEC = ROOT / "openapi" / "openapi.yaml" +PLATFORM_SPEC = ROOT / "openapi" / "platform-openapi.yaml" +COVERAGE = ROOT / "openapi" / "coverage.yaml" +TYPES = ROOT / "src" / "kaitencloud" / "types" +PARAMS = TYPES / "_params.py" + +HTTP_METHODS = ("get", "put", "post", "patch", "delete") +PROSE = frozenset({"description", "examples", "example", "title"}) +PAGE = re.compile(r"^Page[A-Z]") + +HEADER = ( + LICENSE_HEADER + + """ +# Generated by scripts/generate_models.py from {source}. Do not edit by hand: +# change the contract (`task sync:openapi`) or the generator, then run `task generate`. +""" +) + +# Public names for schemas whose contract name is too generic to export on its own. +CLASS_NAMES: dict[str, str] = { + "Basic": "BasicVariant", + "Impact": "MetadataFieldImpact", + "ImpactSample": "MetadataFieldImpactSample", + "RolloutDate": "RolloutDateVariant", + "RolloutPercentage": "RolloutPercentageVariant", +} + +# Names for inline unions, keyed by their members. +UNION_NAMES: dict[frozenset[str], str] = { + frozenset( + {"NumberEntitlementValue", "BooleanEntitlementValue", "ConfigEntitlementValue"} + ): "EntitlementValue", + frozenset( + {"BasicTargeting", "RolloutDateTargeting", "RolloutPercentageTargeting"} + ): "Targeting", +} + +# Names for enums with more than one value, keyed by (schema, property). +ENUM_NAMES: dict[tuple[str, str], str] = { + ("Entitlement", "aggregationMethod"): "AggregationMethod", + ("Entitlement", "resetAnchor"): "ResetAnchor", + ("Entitlement", "resetPeriod"): "ResetPeriod", + ("Entitlement", "type"): "EntitlementType", + ("FeatureFlag", "type"): "FeatureFlagType", + ("Instance", "status"): "InstanceStatus", + ("InstanceEntitlementUsageReportAccepted", "behavior"): "UsageBehavior", + ("InstanceEntitlementUsageReportAccepted", "status"): "UsageReportStatus", + ("InstanceStatusChanged", "new_status"): "InstanceStatus", + ("InstanceStatusChanged", "previous_status"): "InstanceStatus", + ("License", "lifecycleState"): "LicenseLifecycleState", + ("License", "type"): "LicenseType", + ("LicenseEntitlement", "entitlementType"): "LicenseEntitlementType", + ("MetadataField", "resourceType"): "MetadataResourceType", +} + +# Attribute names that would shadow a pydantic.BaseModel member, keyed by (schema, property). +FIELD_NAMES: dict[tuple[str, str], str] = { + ("ConnectorSettingsSchema", "schema"): "json_schema", +} + +ALIAS_DOCS: dict[str, str] = { + "AggregationMethod": "How each reported usage value folds into an entitlement's stored total.", + "DefaultVariant": "What a feature flag serves when no targeting rule matches, discriminated by ``type``.", + "EntitlementType": "The value type of an entitlement in the catalog.", + "EntitlementValue": "A typed entitlement value, discriminated by ``type``: a number, a boolean or a config object.", + "FeatureFlagType": "The value type of a feature flag's variants.", + "InstanceStatus": "The operational status of an instance.", + "LicenseEntitlementType": "The value type of an entitlement granted by a license.", + "LicenseLifecycleState": ( + "Whether a license version may be served: ``DRAFT`` before it goes on sale, " + "``PUBLISHED`` while it is on sale, ``ARCHIVED`` once it has been withdrawn." + ), + "LicenseType": "The kind of a license.", + "MetadataResourceType": "The resource a metadata field applies to.", + "ResetAnchor": "What a periodic usage window is aligned on.", + "ResetPeriod": "How often a periodic entitlement's usage resets.", + "Scope": ( + "A permission an organization credential can carry, ``read:`` or " + "``write:``; ``write`` implies ``read`` on the same resource." + ), + "Targeting": "One targeting rule of a feature flag, discriminated by ``type``.", + "Targetings": "A feature flag's targeting rules, evaluated in order; the first match wins.", + "UsageBehavior": "How a usage report applies: ``append`` folds the value in, ``set`` overwrites it.", + "UsageReportStatus": "Whether a usage report was persisted.", +} + +# Docstrings for the schemas the contract leaves undescribed. +CLASS_DOCS: dict[str, str] = { + "AuditTrail": "One entry of an instance's audit trail.", + "BasicTargeting": "A targeting rule that serves one variant to every context its CEL rule matches.", + "BasicVariant": "A default variant that always serves one named variant.", + "BooleanEntitlementValue": "A boolean entitlement value: whether a feature is included.", + "Component": "A versioned building block of your product, that releases are made of.", + "ConfigEntitlementValue": "A configuration entitlement value: a free-form object.", + "Connector": "A connector registered with this Kaiten deployment.", + "ConnectorActivation": "An organization's activation of a connector.", + "ConnectorSettings": "An organization's settings for a connector.", + "ConnectorSettingsSchema": "The JSON schema a connector's settings are validated against.", + "ConnectorState": "Whether a connector is available, licensed and activated for this organization.", + "Customer": "A customer of your organization: the end client instances are deployed for.", + "CustomerCreationRejected": "A customer creation refused because the organization reached its limit.", + "CustomerIntegration": "A customer's link to a record in a third-party system.", + "CustomerIntegrationResource": "A customer as seen through one of its integrations.", + "Deployment": "A release deployed to a deployment zone: an append-only history record.", + "DeploymentZone": "An environment instances run in, and the release currently deployed to it.", + "Entitlement": "A catalog entry: something a license can grant, and how its usage is measured.", + "EntitlementGroup": "A named group of entitlements.", + "EntitlementGroupSummary": "A reference to an entitlement group.", + "EntitlementGroupUsage": "One entitlement's grant and usage within a group, for one instance.", + "EntitlementUsage": "An instance's consumption of one entitlement, next to the grant it is measured against.", + "ErrorDetail": "One validation problem inside an API error.", + "FeatureFlag": "A feature flag: its variants, targeting rules and default variant.", + "FeatureFlagEvaluated": "A feature flag evaluation, as reported to webhooks.", + "Instance": "A deployment of your product for one customer, under one license.", + "InstanceEntitlementCapExceeded": "Usage crossed an entitlement's included value, within its allowance.", + "InstanceEntitlementUsagePeriodRolledOver": "A periodic usage window closed and a new one opened.", + "InstanceEntitlementUsageReportAccepted": "The outcome of a usage report.", + "InstanceEntitlementUsageWarningThresholdReached": "Usage crossed an entitlement's warning threshold.", + "InstanceEntitlementValueGet": "An entitlement value read for an instance.", + "InstanceIntegration": "An instance's link to a record in a third-party system.", + "InstanceIntegrationResource": "An instance as seen through one of its integrations.", + "InstanceLifecycleStageChanged": "An instance's commercial lifecycle stage changed.", + "InstanceStatusChanged": "An instance's operational status changed.", + "License": "A version of a license family: one set of entitlement grants, with its lifecycle state.", + "LicenseFamilyView": "A license family -- the product -- resolved to one of its versions.", + "LicenseEntitlement": "A grant: the value a license gives one entitlement, and how it is enforced.", + "ManifestFlag": "One flag of the OpenFeature manifest.", + "MetadataField": "A typed field an organization declares on its instances or deployment zones.", + "MetadataFieldImpact": "What a candidate metadata field schema would invalidate.", + "MetadataFieldImpactSample": "A resource a metadata field schema change would invalidate.", + "MetadataFieldReordered": "Metadata fields were given a new display order.", + "NumberEntitlementValue": "A numeric entitlement value: a quota, or an amount consumed.", + "Organization": "A Kaiten organization: a tenant.", + "PlainToken": "A token together with its plaintext secret, which the API returns exactly once.", + "PlatformCredential": "The platform credential (``ksm_...``) a platform client authenticates with.", + "Problem": "An RFC 9457 problem document, which every API error carries.", + "Release": "An immutable, versioned set of components.", + "RolloutDateTargeting": "A targeting rule that shifts traffic between variants over a date range.", + "RolloutDateVariant": "A default variant that shifts traffic between variants over a date range.", + "RolloutPercentageTargeting": "A targeting rule that splits the contexts it matches between variants.", + "RolloutPercentageVariant": "A default variant that splits traffic between variants by weight.", + "RolloutStep": "One end of a date-based rollout.", + "ServiceAccount": "A machine identity inside an organization, owning API tokens.", + "SystemTokenIssuance": "A platform credential minted a token inside this organization.", + "Token": "A token's metadata. Never its secret.", + "User": "A reference to the user behind an action.", + "Variant": "One possible value of a feature flag.", +} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate kaitencloud's models from the contract.") + parser.add_argument( + "--check", action="store_true", help="fail when the committed models are stale" + ) + args = parser.parse_args() + + coverage = load(COVERAGE) + core = load(CORE_SPEC) + platform = load(PLATFORM_SPEC) + + core_source, core_names, core_exports = core_module( + core, excluded=set(coverage["core"]["excluded"] or {}) + ) + platform_source, platform_exports = platform_module( + platform, core, core_names, excluded=set(coverage["platform"]["excluded"] or {}) + ) + files = { + TYPES / "__init__.py": types_package(core_exports, platform_exports), + TYPES / "_core.py": core_source, + TYPES / "_platform.py": platform_source, + TYPES / "_webhooks.py": webhooks_module(core, core_names), + } + + if emit(files, check=args.check): + return 0 + if args.check: + print("the generated models are stale: run `task generate`", file=sys.stderr) + return 1 + return 0 + + +def core_module( + spec: dict[str, Any], *, excluded: set[str] +) -> tuple[str, dict[str, str], list[str]]: + schemas = spec["components"]["schemas"] + roots = response_roots(spec, excluded) | refs_in(spec.get("webhooks") or {}) + emitted = sorted(name for name in closure(schemas, roots) if not PAGE.match(name)) + names = {name: CLASS_NAMES.get(name, name) for name in emitted} + + module = ModelModule(source="openapi/openapi.yaml", schemas=schemas, names=names) + for name in sorted(emitted, key=names.__getitem__): + module.add_schema(name) + module.enums["Scope"] = scopes(spec) + return module.render(), names, module.exports + + +def scopes(spec: dict[str, Any]) -> tuple[str, ...]: + """Every scope an organization credential can carry, as the contract publishes them.""" + published = { + scope + for scheme in (spec.get("components", {}).get("securitySchemes") or {}).values() + for scope in scheme.get("x-kaiten-scopes") or () + } + if not published: + raise SystemExit("the contract publishes no x-kaiten-scopes: Scope would be empty") + return tuple(sorted(published)) + + +def platform_module( + spec: dict[str, Any], + core: dict[str, Any], + core_names: dict[str, str], + *, + excluded: set[str], +) -> tuple[str, list[str]]: + schemas = spec["components"]["schemas"] + core_schemas = core["components"]["schemas"] + + names: dict[str, str] = {} + own: list[str] = [] + for name in sorted(closure(schemas, response_roots(spec, excluded))): + if name in core_names and without_prose(schemas[name]) == without_prose(core_schemas[name]): + # The same shape under the same name: one Python class serves both surfaces. + names[name] = core_names[name] + continue + python = CLASS_NAMES.get(name, name) + if python in core_names.values(): + python = f"Platform{python}" + names[name] = python + own.append(name) + + module = ModelModule(source="openapi/platform-openapi.yaml", schemas=schemas, names=names) + module.imports = {python for name, python in names.items() if name not in own} + for name in own: + module.add_schema(name) + return module.render(), module.exports + + +def types_package(core_exports: list[str], platform_exports: list[str]) -> str: + """``kaitencloud.types``: every generated model, and the hand-written ``*Param`` shapes.""" + groups = ( + ("_core", core_exports), + ("_params", params_exports()), + ("_platform", platform_exports), + ) + lines = [ + HEADER.format(source="the snapshots in openapi/"), + '"""Models and parameter types for the Kaiten API.', + "", + "The models are generated from the API contract. The ``*Param`` typed dictionaries describe", + "the nested values the client methods accept.", + '"""', + "", + ] + for module, names in groups: + lines += [f"from .{module} import (", *(f" {name}," for name in names), ")"] + everything = sorted({name for _, names in groups for name in names}) + lines += ["", "__all__ = [", *(f" {json.dumps(name)}," for name in everything), "]", ""] + return "\n".join(lines) + + +def params_exports() -> list[str]: + """The literal ``__all__`` of the hand-written ``_params.py``, read without importing it.""" + tree = ast.parse(PARAMS.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "__all__" for target in node.targets + ): + return sorted(ast.literal_eval(node.value)) + raise SystemExit(f"{PARAMS.relative_to(ROOT)} has no literal __all__") + + +def webhooks_module(spec: dict[str, Any], core_names: dict[str, str]) -> str: + lines = [ + HEADER.format(source="openapi/openapi.yaml"), + '"""The events Kaiten delivers to webhook endpoints, one model per published contract."""', + "", + "from __future__ import annotations", + "", + "from typing import Annotated, Literal", + "", + "from pydantic import Field", + "", + "from .._models import KaitenModel", + ] + + events: list[tuple[str, str, str, str, str]] = [] + for key, item in sorted((spec.get("webhooks") or {}).items()): + operation = item["post"] + schema = operation["requestBody"]["content"]["application/json"]["schema"] + properties = schema["properties"] + event_type = only_enum_value(properties["type"], f"webhook {key}.type") + if event_type != key: + raise SystemExit(f"webhook {key}: its type enum says {event_type!r}") + event_name = only_enum_value(properties["name"], f"webhook {key}.name") + data = core_names[ref_name(properties["data"]["$ref"])] + python = re.sub(r"^on", "", operation["operationId"]) + "Event" + summary = operation.get("description") or operation.get("summary") or "" + events.append((python, event_name, event_type, data, summary)) + + data_types = sorted({data for _, _, _, data, _ in events}) + lines.append(f"from ._core import {', '.join(data_types)}") + lines += ["", "__all__ = [", ' "WEBHOOK_EVENTS",', ' "WebhookEvent",'] + lines += [f' "{python}",' for python, *_ in sorted(events)] + lines += ["]", ""] + + for python, event_name, event_type, data, summary in events: + lines += ["", f"class {python}(KaitenModel):"] + lines += docstring(f"{summary} Delivered as ``{event_type}``.", " ") + lines += [ + "", + f" name: Literal[{json.dumps(event_name)}]", + ' """The event name."""', + "", + f" type: Literal[{json.dumps(event_type)}]", + ' """The event type: what tells one event from another."""', + "", + f" data: {data}", + ' """What the event is about."""', + "", + ] + + members = "\n".join(f" | {python}" for python, *_ in events).lstrip(" |") + lines += [ + "", + f'WebhookEvent = Annotated[\n {members},\n Field(discriminator="type"),\n]', + '"""Any event Kaiten delivers to a webhook endpoint, discriminated by ``type``."""', + "", + "WEBHOOK_EVENTS: dict[str, type[KaitenModel]] = {", + *(f" {json.dumps(event_type)}: {python}," for python, _, event_type, _, _ in events), + "}", + '"""Every published event type, mapped to the model that parses it."""', + "", + ] + return "\n".join(lines) + + +class ModelModule: + """Renders the models of one contract document into one Python module.""" + + def __init__(self, *, source: str, schemas: dict[str, Any], names: dict[str, str]) -> None: + self.source = source + self.schemas = schemas + self.names = names + self.imports: set[str] = set() + self.enums: dict[str, tuple[Any, ...]] = {} + self.unions: dict[str, tuple[str | None, list[str]]] = {} + self.aliases: dict[str, str] = {} + self.nullable_aliases: set[str] = set() + self.classes: list[str] = [] + self.body: list[str] = [] + + @property + def exports(self) -> list[str]: + return sorted({*self.enums, *self.unions, *self.aliases, *self.classes}) + + def add_schema(self, name: str) -> None: + schema = self.schemas[name] + python = self.names[name] + if "properties" in schema or schema.get("type") == "object": + self.classes.append(python) + self.body += ["", *self.render_class(name, schema), ""] + elif "oneOf" in schema or "anyOf" in schema: + self.union(schema, owner=name, name=python) + else: + annotation = self.annotation(schema, owner=name, prop=None) + self.aliases[python] = annotation + if annotation.endswith("| None"): + self.nullable_aliases.add(python) + + def render_class(self, name: str, schema: dict[str, Any]) -> list[str]: + python = self.names[name] + lines = [f"class {python}(KaitenModel):"] + lines += docstring(schema.get("description") or CLASS_DOCS.get(python), " ") + + required = set(schema.get("required") or []) + properties: dict[str, Any] = schema.get("properties") or {} + if not properties and len(lines) == 1: + lines.append(" pass") + + for wire, node in properties.items(): + attribute = FIELD_NAMES.get((name, wire), snake_case(wire)) + if keyword.iskeyword(attribute) or hasattr(BaseModel, attribute): + raise SystemExit( + f"{name}.{wire}: {attribute!r} would shadow a pydantic member; " + "give it a name in FIELD_NAMES" + ) + + annotation = self.annotation(node, owner=name, prop=wire) + if wire in required: + default = f" = Field(alias={json.dumps(wire)})" if attribute != wire else "" + else: + if not self.is_nullable(annotation): + annotation += " | None" + default = ( + f" = Field(default=None, alias={json.dumps(wire)})" + if attribute != wire + else " = None" + ) + + lines += ["", f" {attribute}: {annotation}{default}"] + lines += docstring(node.get("description"), " ") + return lines + + def annotation(self, node: dict[str, Any], *, owner: str, prop: str | None) -> str: + where = f"{owner}.{prop}" if prop else owner + if "$ref" in node: + return self.names[ref_name(node["$ref"])] + for key in ("oneOf", "anyOf"): + if key in node: + return self.union(node, owner=owner) + + kind = node.get("type") + nullable = False + if isinstance(kind, list): + nullable = "null" in kind + rest = [item for item in kind if item != "null"] + if len(rest) > 1: + raise SystemExit(f"{where}: multi-type schemas are not supported ({kind})") + kind = rest[0] if rest else None + + if kind is None: + text = "Any" + elif kind == "string": + text = self.string(node, owner=owner, prop=prop) + elif kind == "integer": + text = "int" + elif kind == "number": + text = "float" + elif kind == "boolean": + text = "bool" + elif kind == "array": + text = f"list[{self.annotation(node.get('items') or {}, owner=owner, prop=prop)}]" + elif kind == "object": + if node.get("properties"): + raise SystemExit(f"{where}: inline object schemas are not supported") + extra = node.get("additionalProperties") + value = ( + self.annotation(extra, owner=owner, prop=prop) + if isinstance(extra, dict) and extra + else "Any" + ) + text = f"dict[str, {value}]" + else: + raise SystemExit(f"{where}: unsupported schema type {kind!r}") + return f"{text} | None" if nullable else text + + def string(self, node: dict[str, Any], *, owner: str, prop: str | None) -> str: + values = node.get("enum") + if values: + if len(values) == 1: + return f"Literal[{json.dumps(values[0])}]" + name = ENUM_NAMES.get((owner, prop or "")) + if name is None: + raise SystemExit(f"{owner}.{prop}: name this enum in ENUM_NAMES ({values})") + known = self.enums.setdefault(name, tuple(values)) + if known != tuple(values): + raise SystemExit(f"{name}: {owner}.{prop} has {values}, elsewhere {list(known)}") + return f"{name} | str" + if node.get("format") == "date-time": + return "datetime" + return "str" + + def union(self, node: dict[str, Any], *, owner: str, name: str | None = None) -> str: + key = "oneOf" if "oneOf" in node else "anyOf" + members: list[str] = [] + for member in node[key]: + if "$ref" not in member: + raise SystemExit(f"{owner}: inline union members are not supported") + members.append(ref_name(member["$ref"])) + + name = name or UNION_NAMES.get(frozenset(members)) + if name is None: + raise SystemExit(f"{owner}: name the union of {members} in UNION_NAMES") + tag = (node.get("discriminator") or {}).get("propertyName") or self.shared_tag(members) + self.unions[name] = (tag, [self.names[member] for member in members]) + return name + + def shared_tag(self, members: Iterable[str]) -> str | None: + """``type``, when every member pins it to its own single value.""" + tags = [] + for member in members: + enum = (self.schemas[member].get("properties") or {}).get("type", {}).get("enum") or [] + if len(enum) != 1: + return None + tags.append(enum[0]) + return "type" if len(set(tags)) == len(tags) else None + + def is_nullable(self, annotation: str) -> bool: + return annotation.endswith("| None") or annotation in self.nullable_aliases + + def render(self) -> str: + lines = [ + HEADER.format(source=self.source), + "from __future__ import annotations", + "", + "from datetime import datetime", + "from typing import Annotated, Any, Literal", + "", + "from pydantic import Field", + "", + "from .._models import KaitenModel", + ] + if self.imports: + lines.append(f"from ._core import {', '.join(sorted(self.imports))}") + lines += [ + "", + "__all__ = [", + *(f" {json.dumps(name)}," for name in self.exports), + "]", + "", + ] + + for name, values in sorted(self.enums.items()): + lines += ["", f"{name} = Literal[{', '.join(json.dumps(value) for value in values)}]"] + lines += docstring(ALIAS_DOCS.get(name), "") + lines += self.body + + for name, (tag, members) in sorted(self.unions.items()): + union = " | ".join(members) + if tag: + lines += [ + "", + f"{name} = Annotated[{union}, Field(discriminator={json.dumps(tag)})]", + ] + else: + lines += ["", f"{name} = {union}"] + lines += docstring(ALIAS_DOCS.get(name), "") + for name, annotation in sorted(self.aliases.items()): + lines += ["", f"{name} = {annotation}"] + lines += docstring(ALIAS_DOCS.get(name), "") + + if self.classes: + # Annotations are strings under `from __future__ import annotations`, and some name + # aliases defined after the classes. Resolving them here, once, makes a broken + # model an import-time error instead of a surprise on the first response it parses. + lines += ["", "", f"for _model in ({', '.join(self.classes)},):"] + lines += [" _model.model_rebuild()", "del _model", ""] + return "\n".join(lines) + "\n" + + +def load(path: Path) -> Any: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def ref_name(ref: str) -> str: + return ref.rsplit("/", 1)[-1] + + +def refs_in(node: Any) -> set[str]: + found: set[str] = set() + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + found.add(ref_name(ref)) + for value in node.values(): + found |= refs_in(value) + elif isinstance(node, list): + for value in node: + found |= refs_in(value) + return found + + +def operations(spec: dict[str, Any]) -> Iterator[dict[str, Any]]: + for item in spec["paths"].values(): + for method in HTTP_METHODS: + if method in item: + yield item[method] + + +def response_roots(spec: dict[str, Any], excluded: set[str]) -> set[str]: + roots: set[str] = set() + for operation in operations(spec): + if operation["operationId"] in excluded: + continue + for response in (operation.get("responses") or {}).values(): + for media in (response.get("content") or {}).values(): + roots |= refs_in(media.get("schema") or {}) + return roots + + +def closure(schemas: dict[str, Any], roots: Iterable[str]) -> set[str]: + seen: set[str] = set() + pending = list(roots) + while pending: + name = pending.pop() + if name not in seen: + seen.add(name) + pending.extend(refs_in(schemas[name]) - seen) + return seen + + +def without_prose(node: Any) -> Any: + """The schema minus its documentation, to tell whether two schemas describe one shape.""" + if isinstance(node, dict): + return {key: without_prose(value) for key, value in node.items() if key not in PROSE} + if isinstance(node, list): + return [without_prose(value) for value in node] + return node + + +def only_enum_value(node: dict[str, Any], where: str) -> str: + values = node.get("enum") or [] + if len(values) != 1: + raise SystemExit(f"{where}: expected a single-value enum, got {values}") + return str(values[0]) + + +def snake_case(name: str) -> str: + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) + name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + return name.replace("-", "_").lower() + + +def docstring(text: str | None, indent: str) -> list[str]: + if not text: + return [] + text = " ".join(text.split()).replace("\\", "\\\\").replace('"""', '\\"\\"\\"') + if text.endswith('"'): + text += " " + if len(indent) + len(text) + 6 <= 100: + return [f'{indent}"""{text}"""'] + wrapped = textwrap.wrap(text, width=92 - len(indent)) + return [ + f'{indent}"""{wrapped[0]}', + *(f"{indent}{line}" for line in wrapped[1:]), + f'{indent}"""', + ] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sync_openapi.py b/scripts/sync_openapi.py new file mode 100644 index 0000000..834f365 --- /dev/null +++ b/scripts/sync_openapi.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Copy the API contract from a kaiten checkout into ``openapi/``, recording where it came from. + +Usage:: + + uv run python scripts/sync_openapi.py --kaiten ../kaiten --ref origin/main [--fetch] + +The snapshots are read from a git revision of the checkout, never from its working tree, so +what lands here is a committed state of the contract rather than someone's local edit. Run +``task generate`` afterwards -- ``task sync:openapi`` does both -- then let the tests say what +the new contract changed. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FILES = { + "openapi.yaml": "app/openapi.yaml", + "platform-openapi.yaml": "app/platform-openapi.yaml", +} + +SOURCE = """\ +# Where the two snapshots in this directory were copied from. Rewritten by +# `task sync:openapi`; never edit the snapshots themselves by hand. +repository: kaitencloud/kaiten +ref: {ref} +commit: {commit} +synced_at: "{today}" +files: + openapi.yaml: app/openapi.yaml + platform-openapi.yaml: app/platform-openapi.yaml +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Copy the API contract from a kaiten checkout.") + parser.add_argument("--kaiten", default="../kaiten", help="path to the kaiten checkout") + parser.add_argument("--ref", default="origin/main", help="the revision to copy from") + parser.add_argument("--fetch", action="store_true", help="git fetch origin first") + args = parser.parse_args() + + kaiten = Path(args.kaiten).resolve() + if not (kaiten / ".git").exists(): + print(f"{kaiten} is not a git checkout of kaitencloud/kaiten", file=sys.stderr) + return 1 + + if args.fetch: + git(kaiten, "fetch", "--quiet", "origin") + commit = git(kaiten, "rev-parse", "--verify", f"{args.ref}^{{commit}}").strip() + + for target, source in FILES.items(): + path = ROOT / "openapi" / target + contents = git(kaiten, "show", f"{commit}:{source}") + changed = not path.exists() or path.read_text(encoding="utf-8") != contents + path.write_text(contents, encoding="utf-8") + print(f"{'updated' if changed else 'unchanged'} openapi/{target}") + + (ROOT / "openapi" / "source.yaml").write_text( + SOURCE.format(ref=args.ref, commit=commit, today=date.today().isoformat()), + encoding="utf-8", + ) + print(f"synced from kaitencloud/kaiten@{commit[:10]} ({args.ref})") + return 0 + + +def git(checkout: Path, *arguments: str) -> str: + return subprocess.run( + ["git", "-C", str(checkout), *arguments], check=True, capture_output=True, text=True + ).stdout + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/unasync.py b/scripts/unasync.py new file mode 100644 index 0000000..cb0c32b --- /dev/null +++ b/scripts/unasync.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Generate the synchronous SDK from the asynchronous one. + +``src/kaitencloud/_async`` is the only hand-written implementation of the clients and their +resources. ``src/kaitencloud/_sync`` is derived from it token by token, so the two cannot +drift: a fix lands once and reaches both. Usage:: + + uv run python scripts/unasync.py # regenerate src/kaitencloud/_sync + uv run python scripts/unasync.py --check # exit 1 when _sync is stale + +The rules are few and mechanical, and the async source follows the conventions they rely on: +coroutine helpers are named ``async_*`` (``async_sleep`` becomes ``sleep``), async classes are +named ``Async*``, and prose avoids the words the rules rewrite. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from _codegen import LICENSE_HEADER, ROOT, emit + +SOURCE = ROOT / "src" / "kaitencloud" / "_async" +TARGET = ROOT / "src" / "kaitencloud" / "_sync" + +HEADER = """\ +# Generated by scripts/unasync.py from src/kaitencloud/_async/{path}. Do not edit by hand: +# change the async source, then run `task generate`. +""" + +RULES: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"\basync def\b"), "def"), + (re.compile(r"\basync with\b"), "with"), + (re.compile(r"\basync for\b"), "for"), + (re.compile(r"\bawait "), ""), + (re.compile(r"\b__aenter__\b"), "__enter__"), + (re.compile(r"\b__aexit__\b"), "__exit__"), + (re.compile(r"\baclose\b"), "close"), + (re.compile(r"\basync_(\w+)"), r"\1"), + (re.compile(r"\b_Async(?=[A-Z])"), "_"), + (re.compile(r"\bAsync(?=[A-Z])"), ""), + (re.compile(r"\b_async\b"), "_sync"), +] + + +def unasync(source: str) -> str: + for pattern, replacement in RULES: + source = pattern.sub(replacement, source) + return source + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate the synchronous SDK from the async one.") + parser.add_argument("--check", action="store_true", help="fail when _sync is stale") + args = parser.parse_args() + + files: dict[Path, str] = {} + for path in sorted(SOURCE.rglob("*.py")): + relative = path.relative_to(SOURCE) + source = path.read_text(encoding="utf-8") + if not source.startswith(LICENSE_HEADER): + print(f"{path} does not open with the license header", file=sys.stderr) + return 1 + body = source.removeprefix(LICENSE_HEADER).lstrip("\n") + files[TARGET / relative] = ( + LICENSE_HEADER + "\n" + HEADER.format(path=relative.as_posix()) + unasync(body) + ) + orphans = [path for path in TARGET.rglob("*.py") if path not in files] + + if emit(files, check=args.check, orphans=orphans): + return 0 + if args.check: + print("src/kaitencloud/_sync is stale: run `task generate`", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/kaitencloud/__init__.py b/src/kaitencloud/__init__.py new file mode 100644 index 0000000..c2db8fe --- /dev/null +++ b/src/kaitencloud/__init__.py @@ -0,0 +1,95 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The official Python SDK for Kaiten. + +:: + + from kaitencloud import KaitenClient + + client = KaitenClient(token="ksh_...") + for instance in client.instances.list(): + print(instance.slug, instance.status) +""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any + +from . import targeting, types, usage +from ._async.client import AsyncKaitenClient, AsyncKaitenPlatformClient +from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT +from ._exceptions import ( + APIConnectionError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + ConflictError, + CredentialError, + InternalServerError, + KaitenError, + NotFoundError, + PaginationError, + PermissionDeniedError, + RateLimitError, + ServiceUnavailableError, + ThresholdExceededError, + UnprocessableEntityError, + WebhookPayloadError, + WebhookVerificationError, +) +from ._models import KaitenModel +from ._sync.client import KaitenClient, KaitenPlatformClient +from ._types import NOT_GIVEN, NotGiven +from ._version import __version__ +from .usage import UNLIMITED + +if TYPE_CHECKING: + from . import webhooks + +__all__ = [ + "DEFAULT_MAX_RETRIES", + "DEFAULT_TIMEOUT", + "NOT_GIVEN", + "UNLIMITED", + "APIConnectionError", + "APIResponseValidationError", + "APIStatusError", + "APITimeoutError", + "AsyncKaitenClient", + "AsyncKaitenPlatformClient", + "AuthenticationError", + "BadRequestError", + "ConflictError", + "CredentialError", + "InternalServerError", + "KaitenClient", + "KaitenError", + "KaitenModel", + "KaitenPlatformClient", + "NotFoundError", + "NotGiven", + "PaginationError", + "PermissionDeniedError", + "RateLimitError", + "ServiceUnavailableError", + "ThresholdExceededError", + "UnprocessableEntityError", + "WebhookPayloadError", + "WebhookVerificationError", + "__version__", + "targeting", + "types", + "usage", + "webhooks", +] + + +def __getattr__(name: str) -> Any: + # The webhook event models are only loaded by the code that receives webhooks. + if name == "webhooks": + return importlib.import_module(f"{__name__}.webhooks") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/kaitencloud/_async/__init__.py b/src/kaitencloud/_async/__init__.py new file mode 100644 index 0000000..3cbfb5d --- /dev/null +++ b/src/kaitencloud/_async/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The Kaiten clients and their resources.""" diff --git a/src/kaitencloud/_async/_http.py b/src/kaitencloud/_async/_http.py new file mode 100644 index 0000000..b96196c --- /dev/null +++ b/src/kaitencloud/_async/_http.py @@ -0,0 +1,327 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The transport both clients share: authentication, retries, errors and pagination.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Mapping +from typing import Any, TypeVar + +import httpx +from pydantic import BaseModel, ValidationError + +from .._constants import DEFAULT_TIMEOUT, MAX_PAGE_SIZE +from .._exceptions import ( + APIConnectionError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + PaginationError, + make_status_error, +) +from .._types import NOT_GIVEN, AsyncCredential, NotGiven, TimeoutTypes +from .._utils import USER_AGENT, Surface, async_resolve_token, async_sleep, check_token, retry_delay + +__all__ = ["AsyncAPIClient"] + +ModelT = TypeVar("ModelT", bound=BaseModel) + +log = logging.getLogger("kaitencloud") + +# Methods a retry cannot turn into a second effect. POST and PATCH are not among them: a usage +# report retried after a lost response is usage counted twice, and usage is what is billed. +IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) + +# Failures that happen before a request is sent, and so are safe to retry whatever its method. +NEVER_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) + + +class AsyncAPIClient: + """Sends a client's requests: authenticates, retries what is safe, maps errors, walks pages.""" + + def __init__( + self, + *, + base_url: str, + credential: AsyncCredential, + surface: Surface, + max_retries: int, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + if max_retries < 0: + raise ValueError(f"max_retries must be zero or more, got {max_retries}") + self.base_url = base_url + self.credential = credential + self.surface = surface + self.max_retries = max_retries + self.timeout = timeout + self.default_headers = dict(default_headers or {}) + self.owns_http_client = http_client is None + self.http_client = http_client or httpx.AsyncClient( + timeout=DEFAULT_TIMEOUT if isinstance(timeout, NotGiven) else timeout + ) + + def copy( + self, + *, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | NotGiven = NOT_GIVEN, + ) -> AsyncAPIClient: + """A transport with some options changed, sharing this one's connection pool.""" + return AsyncAPIClient( + base_url=self.base_url, + credential=self.credential, + surface=self.surface, + max_retries=self.max_retries if isinstance(max_retries, NotGiven) else max_retries, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + default_headers=( + self.default_headers if isinstance(default_headers, NotGiven) else default_headers + ), + http_client=self.http_client, + ) + + async def close(self) -> None: + if self.owns_http_client: + await self.http_client.aclose() + + async def request( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + body: Any = None, + idempotent: bool | None = None, + errors: Mapping[int, type[APIStatusError]] | None = None, + ) -> httpx.Response: + """Send one operation and return its successful response. + + A request is retried, up to ``max_retries`` times, when it failed in a way worth + retrying (a connection error, a 408, 429 or 5xx) *and* repeating it is safe: its method + is idempotent, ``idempotent=True`` says so, or it never left the machine. A 401 or 403 is + never retried -- the SDK cannot mint a better credential, and hammering a rejected one + only burns the rate budget. + """ + url = self.base_url + path.lstrip("/") + retryable = method in IDEMPOTENT_METHODS if idempotent is None else idempotent + attempt = 0 + + while True: + request = self._build( + method, url, params=_query(params), body=body, headers=await self._headers() + ) + try: + response = await self.http_client.send(request) + except httpx.TransportError as error: + if attempt < self.max_retries and (retryable or isinstance(error, NEVER_SENT)): + await self._back_off(request, attempt, repr(error)) + attempt += 1 + continue + if isinstance(error, httpx.TimeoutException): + raise APITimeoutError(request=request) from error + raise APIConnectionError(request=request) from error + + if response.is_success: + return response + + status = response.status_code + if retryable and attempt < self.max_retries and (status in (408, 429) or status >= 500): + await self._back_off( + request, attempt, f"HTTP {status}", response.headers.get("retry-after") + ) + attempt += 1 + continue + raise make_status_error(response, overrides=errors) + + async def get( + self, path: str, model: type[ModelT], *, params: Mapping[str, Any] | None = None + ) -> ModelT: + return await self.send("GET", path, model, params=params) + + async def get_list( + self, path: str, model: type[ModelT], *, params: Mapping[str, Any] | None = None + ) -> list[ModelT]: + """GET an unpaginated array, which the contracts type as nullable: null is empty.""" + response = await self.request("GET", path, params=params) + data = self.decode(response) + if data is None: + return [] + if not isinstance(data, list): + raise APIResponseValidationError( + f"GET {path}: expected a JSON array, got {type(data).__name__}", response=response + ) + return [self.validate(model, item, response) for item in data] + + async def send( + self, + method: str, + path: str, + model: type[ModelT], + *, + params: Mapping[str, Any] | None = None, + body: Any = None, + idempotent: bool | None = None, + errors: Mapping[int, type[APIStatusError]] | None = None, + ) -> ModelT: + response = await self.request( + method, path, params=params, body=body, idempotent=idempotent, errors=errors + ) + return self.validate(model, self.decode(response), response) + + async def send_empty( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + body: Any = None, + idempotent: bool | None = None, + ) -> None: + await self.request(method, path, params=params, body=body, idempotent=idempotent) + + async def paginate( + self, + path: str, + model: type[ModelT], + *, + params: Mapping[str, Any] | None = None, + limit: int | None = None, + ) -> AsyncIterator[ModelT]: + """Yield every row of a cursor-paginated collection, page after page. + + ``limit`` caps how many rows are yielded, and no page asks for more rows than are still + wanted. Without it, the walk ends only when the API says there is nothing more. + """ + filters = dict(params or {}) + cursor: str | None = None + yielded = 0 + items: list[Any] + has_more: object + next_cursor: object + while True: + size = MAX_PAGE_SIZE if limit is None else min(MAX_PAGE_SIZE, limit - yielded) + response = await self.request( + "GET", path, params={**filters, "limit": size, "cursor": cursor} + ) + page = self.decode(response) + + if isinstance(page, list): + # A deployment older than cursor pagination answers the whole collection. + items, has_more, next_cursor = page, False, None + elif isinstance(page, dict) and isinstance(page.get("items"), list): + items, has_more, next_cursor = ( + page["items"], + page.get("hasMore"), + page.get("nextCursor"), + ) + else: + raise APIResponseValidationError( + f"GET {path}: expected a page of items", response=response + ) + + for item in items: + yield self.validate(model, item, response) + yielded += 1 + if limit is not None and yielded >= limit: + return + if not has_more: + return + if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor: + raise PaginationError( + f"GET {path}: the API reported more rows but returned no usable next cursor" + ) + cursor = next_cursor + + async def list_all( + self, + path: str, + model: type[ModelT], + *, + params: Mapping[str, Any] | None = None, + limit: int | None = None, + ) -> list[ModelT]: + rows: list[ModelT] = [] + async for row in self.paginate(path, model, params=params, limit=limit): + rows.append(row) + return rows + + @staticmethod + def decode(response: httpx.Response) -> Any: + if not response.content: + return None + try: + return response.json() + except ValueError as error: + raise APIResponseValidationError( + f"{response.request.method} {response.request.url.path}: the response is not JSON", + response=response, + ) from error + + @staticmethod + def validate(model: type[ModelT], data: Any, response: httpx.Response) -> ModelT: + try: + return model.model_validate(data) + except ValidationError as error: + raise APIResponseValidationError( + f"{response.request.method} {response.request.url.path}: the response does not " + f"match {model.__name__}: {error}", + response=response, + ) from error + + def _build( + self, + method: str, + url: str, + *, + params: dict[str, Any] | None, + body: Any, + headers: dict[str, str], + ) -> httpx.Request: + if isinstance(self.timeout, NotGiven): + # The HTTP client's own timeout applies: the default one, or the caller's. + return self.http_client.build_request( + method, url, params=params, json=body, headers=headers + ) + return self.http_client.build_request( + method, url, params=params, json=body, headers=headers, timeout=self.timeout + ) + + async def _headers(self) -> dict[str, str]: + token = check_token(await async_resolve_token(self.credential), self.surface) + return { + "Accept": "application/json", + "User-Agent": USER_AGENT, + **self.default_headers, + "Authorization": f"Bearer {token}", + } + + async def _back_off( + self, request: httpx.Request, attempt: int, reason: str, retry_after: str | None = None + ) -> None: + delay = retry_delay(attempt, retry_after) + log.info( + "Retrying %s %s in %.2fs (retry %d of %d) after %s", + request.method, + request.url.path, + delay, + attempt + 1, + self.max_retries, + reason, + ) + await async_sleep(delay) + + +def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not params: + return None + query: dict[str, Any] = {} + for key, value in params.items(): + if value is None: + continue + query[key] = ("true" if value else "false") if isinstance(value, bool) else value + return query diff --git a/src/kaitencloud/_async/client.py b/src/kaitencloud/_async/client.py new file mode 100644 index 0000000..bd805bc --- /dev/null +++ b/src/kaitencloud/_async/client.py @@ -0,0 +1,310 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The Kaiten API clients: one per API surface.""" + +from __future__ import annotations + +import copy +import os +from collections.abc import Mapping +from types import TracebackType + +import httpx +from typing_extensions import Self + +from .._constants import ( + DEFAULT_MAX_RETRIES, + ENV_BASE_URL, + ENV_PLATFORM_BASE_URL, + ENV_PLATFORM_TOKEN, + ENV_TOKEN, +) +from .._exceptions import CredentialError +from .._types import NOT_GIVEN, AsyncCredential, NotGiven, TimeoutTypes +from .._utils import normalize_base_url +from ..types import PlatformCredential +from ._http import AsyncAPIClient +from .resources import ( + AsyncComponents, + AsyncConnectors, + AsyncCustomers, + AsyncDeploymentZones, + AsyncEntitlementGroups, + AsyncEntitlements, + AsyncFeatureFlags, + AsyncInstances, + AsyncIntegrations, + AsyncLicenseFamilies, + AsyncLicenses, + AsyncMetadataFields, + AsyncReleases, + AsyncServiceAccounts, +) +from .resources.platform import ( + AsyncPlatformConnectors, + AsyncPlatformOrganizations, + AsyncPlatformTokens, + AsyncPlatformUsers, +) + +__all__ = ["AsyncKaitenClient", "AsyncKaitenPlatformClient"] + + +class _AsyncBaseClient: + _api: AsyncAPIClient + + @property + def base_url(self) -> str: + """The API prefix every request goes to, always ending in ``/api/``.""" + return self._api.base_url + + @property + def max_retries(self) -> int: + """How many times a failed request that is safe to repeat is retried.""" + return self._api.max_retries + + def with_options( + self, + *, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | NotGiven = NOT_GIVEN, + ) -> Self: + """Return a copy of this client with some options changed. + + The copy shares this client's connection pool, so it is cheap to make per call:: + + client.with_options(timeout=5, max_retries=0).customers.get("acme") + + Closing the copy leaves the pool open; closing the original closes it for both. + """ + clone = copy.copy(self) + clone._api = self._api.copy( + timeout=timeout, max_retries=max_retries, default_headers=default_headers + ) + clone._bind() + return clone + + async def close(self) -> None: + """Close the connection pool, unless it was passed in as ``http_client``.""" + await self._api.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + def _bind(self) -> None: + raise NotImplementedError + + def __repr__(self) -> str: + return f"{type(self).__name__}(base_url={self.base_url!r})" + + +class AsyncKaitenClient(_AsyncBaseClient): + """Client for the Kaiten Core API. + + The Core API is what an organization's own credential talks to: an organization token + (``ksh_...``) or a JWT from your identity provider. It covers customers, instances, + licenses, entitlements and usage, feature flags, releases and connectors:: + + async with AsyncKaitenClient(token="ksh_...") as client: + for customer in await client.customers.list(): + print(customer.slug) + + Args: + token: The credential, or a callable returning it -- called before every request, so a + rotated token is picked up. Defaults to the ``KAITEN_AUTH_TOKEN`` environment + variable. A platform credential (``ksm_...``) is refused: use + :class:`AsyncKaitenPlatformClient`. + base_url: Where your Kaiten deployment's API lives, e.g. ``http://localhost:6000``. + ``/api`` is appended when missing. Defaults to ``KAITEN_BASE_URL``; there is no + default beyond it, since every deployment has an address of its own. + timeout: Seconds, or an :class:`httpx.Timeout`. Defaults to 30 seconds. + max_retries: How many times a failed request that is safe to repeat is retried. + default_headers: Headers sent with every request. + http_client: An :class:`httpx.AsyncClient` to send requests with, for proxies, custom + TLS or transports. The Kaiten client never closes a client it did not create. + """ + + components: AsyncComponents + """Components: the versioned building blocks releases are made of.""" + + connectors: AsyncConnectors + """Connectors: activation and settings of the integrations this deployment offers.""" + + customers: AsyncCustomers + """Customers: the end clients of your product.""" + + deployment_zones: AsyncDeploymentZones + """Deployment zones: the environments instances run in.""" + + entitlement_groups: AsyncEntitlementGroups + """Entitlement groups: named sets of entitlements.""" + + entitlements: AsyncEntitlements + """Entitlements: the catalog of what licenses can grant.""" + + feature_flags: AsyncFeatureFlags + """Feature flags: their definitions. Evaluate them through OpenFeature.""" + + instances: AsyncInstances + """Instances: deployments of your product, their usage and audit trail.""" + + integrations: AsyncIntegrations + """Customers and instances addressed by their identifier in a third-party system.""" + + license_families: AsyncLicenseFamilies + """License families: the products, each resolved to the version it currently serves.""" + + licenses: AsyncLicenses + """Licenses: the versions of a license family, and the entitlement values they grant.""" + + metadata_fields: AsyncMetadataFields + """Metadata fields: the typed fields of instances and deployment zones.""" + + releases: AsyncReleases + """Releases: immutable, versioned sets of components.""" + + service_accounts: AsyncServiceAccounts + """Service accounts and their tokens.""" + + def __init__( + self, + *, + token: AsyncCredential | None = None, + base_url: str | None = None, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + credential = token if token is not None else os.environ.get(ENV_TOKEN) + if credential is None or (isinstance(credential, str) and not credential.strip()): + raise CredentialError(f"No credential: pass token=... or set {ENV_TOKEN}.") + url = base_url or os.environ.get(ENV_BASE_URL) + if not url or not url.strip(): + raise ValueError( + "No Kaiten API address: pass base_url=... (your deployment's, e.g. " + f"http://localhost:6000) or set {ENV_BASE_URL}." + ) + self._api = AsyncAPIClient( + base_url=normalize_base_url(url), + credential=credential, + surface="core", + max_retries=max_retries, + timeout=timeout, + default_headers=default_headers, + http_client=http_client, + ) + self._bind() + + def _bind(self) -> None: + api = self._api + self.components = AsyncComponents(api) + self.connectors = AsyncConnectors(api) + self.customers = AsyncCustomers(api) + self.deployment_zones = AsyncDeploymentZones(api) + self.entitlement_groups = AsyncEntitlementGroups(api) + self.entitlements = AsyncEntitlements(api) + self.feature_flags = AsyncFeatureFlags(api) + self.instances = AsyncInstances(api) + self.integrations = AsyncIntegrations(api) + self.license_families = AsyncLicenseFamilies(api) + self.licenses = AsyncLicenses(api) + self.metadata_fields = AsyncMetadataFields(api) + self.releases = AsyncReleases(api) + self.service_accounts = AsyncServiceAccounts(api) + + +class AsyncKaitenPlatformClient(_AsyncBaseClient): + """Client for the Kaiten Platform API. + + The Platform API administers a deployment across organizations: it ensures + organizations exist, mints the organization tokens that Core API callers use, and holds + the connector registry. It listens on its own port, on no public route, and accepts only + a platform credential (``ksm_...``):: + + async with AsyncKaitenPlatformClient( + base_url="http://kaiten-api.kaiten.svc:6001", token="ksm_..." + ) as platform: + organization = await platform.organizations.ensure(external_id="org_2abcDEF") + minted = await platform.tokens.mint(organization.id, name="billing-sync") + + Args: + token: The platform credential, or a callable returning it. Defaults to the + ``KAITEN_PLATFORM_TOKEN`` environment variable. + base_url: The Platform API listener -- not the Core API's address, which answers 404 + to every operation here. Defaults to ``KAITEN_PLATFORM_BASE_URL``; required. + timeout: Seconds, or an :class:`httpx.Timeout`. Defaults to 30 seconds. + max_retries: How many times a failed request that is safe to repeat is retried. + default_headers: Headers sent with every request. + http_client: An :class:`httpx.AsyncClient` to send requests with. + """ + + connectors: AsyncPlatformConnectors + """The deployment-wide connector registry.""" + + organizations: AsyncPlatformOrganizations + """Organizations: ensure, read, delete, and remove memberships.""" + + tokens: AsyncPlatformTokens + """Organization tokens this platform credential mints, lists and revokes.""" + + users: AsyncPlatformUsers + """Users, across every organization.""" + + def __init__( + self, + *, + token: AsyncCredential | None = None, + base_url: str | None = None, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + credential = token if token is not None else os.environ.get(ENV_PLATFORM_TOKEN) + if credential is None or (isinstance(credential, str) and not credential.strip()): + raise CredentialError( + f"No platform credential: pass token=... or set {ENV_PLATFORM_TOKEN}." + ) + url = base_url or os.environ.get(ENV_PLATFORM_BASE_URL) + if not url: + raise ValueError( + "The Platform API has no default address: pass base_url=... (its own listener, " + f"e.g. http://kaiten-api:6001) or set {ENV_PLATFORM_BASE_URL}." + ) + self._api = AsyncAPIClient( + base_url=normalize_base_url(url), + credential=credential, + surface="platform", + max_retries=max_retries, + timeout=timeout, + default_headers=default_headers, + http_client=http_client, + ) + self._bind() + + async def me(self) -> PlatformCredential: + """Describe the platform credential this client authenticates with. + + Its name, scopes, expiry and identity -- never its value, which exists only at creation. + Cheap enough to probe a stored credential with: it is the one way to tell a revoked or + expired credential from a working one without attempting a write. + """ + return await self._api.get("/platform/me", PlatformCredential) + + def _bind(self) -> None: + api = self._api + self.connectors = AsyncPlatformConnectors(api) + self.organizations = AsyncPlatformOrganizations(api) + self.tokens = AsyncPlatformTokens(api) + self.users = AsyncPlatformUsers(api) diff --git a/src/kaitencloud/_async/resources/__init__.py b/src/kaitencloud/_async/resources/__init__.py new file mode 100644 index 0000000..8a40fa4 --- /dev/null +++ b/src/kaitencloud/_async/resources/__init__.py @@ -0,0 +1,34 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from .components import AsyncComponents +from .connectors import AsyncConnectors +from .customers import AsyncCustomers +from .deployment_zones import AsyncDeploymentZones +from .entitlement_groups import AsyncEntitlementGroups +from .entitlements import AsyncEntitlements +from .feature_flags import AsyncFeatureFlags +from .instances import AsyncInstances +from .integrations import AsyncIntegrations +from .license_families import AsyncLicenseFamilies +from .licenses import AsyncLicenses +from .metadata_fields import AsyncMetadataFields +from .releases import AsyncReleases +from .service_accounts import AsyncServiceAccounts + +__all__ = [ + "AsyncComponents", + "AsyncConnectors", + "AsyncCustomers", + "AsyncDeploymentZones", + "AsyncEntitlementGroups", + "AsyncEntitlements", + "AsyncFeatureFlags", + "AsyncInstances", + "AsyncIntegrations", + "AsyncLicenseFamilies", + "AsyncLicenses", + "AsyncMetadataFields", + "AsyncReleases", + "AsyncServiceAccounts", +] diff --git a/src/kaitencloud/_async/resources/_base.py b/src/kaitencloud/_async/resources/_base.py new file mode 100644 index 0000000..6c8720e --- /dev/null +++ b/src/kaitencloud/_async/resources/_base.py @@ -0,0 +1,20 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from .._http import AsyncAPIClient + +__all__ = ["AsyncResource"] + + +class AsyncResource: + """Base class of a resource namespace, such as ``client.customers``.""" + + __slots__ = ("_api",) + + def __init__(self, api: AsyncAPIClient) -> None: + self._api = api + + def __repr__(self) -> str: + return f"<{type(self).__name__} base_url={self._api.base_url!r}>" diff --git a/src/kaitencloud/_async/resources/components.py b/src/kaitencloud/_async/resources/components.py new file mode 100644 index 0000000..8f91e7a --- /dev/null +++ b/src/kaitencloud/_async/resources/components.py @@ -0,0 +1,76 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins + +from ..._utils import api_path, compact +from ...types import Component +from ._base import AsyncResource + +__all__ = ["AsyncComponents"] + + +class AsyncComponents(AsyncResource): + """Components: the versioned building blocks of your product that releases are made of.""" + + async def list(self) -> builtins.list[Component]: + """Return every component, walking all pages.""" + return await self._api.list_all("/components", Component) + + async def get(self, component_slug: str) -> Component: + """Return the component identified by ``component_slug``.""" + return await self._api.get(api_path("components", component_slug), Component) + + async def create( + self, + *, + name: str, + version: str, + description: str | None = None, + previous_component_id: str | None = None, + slug: str | None = None, + ) -> Component: + """Create a component. + + Args: + name: The component's name, e.g. ``"api-gateway"``. + version: Its version, e.g. ``"v1.2.3"``. + description: A description, up to 500 characters. + previous_component_id: The id of the component this one succeeds. The link is set + here, once: an update cannot change it. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = compact( + { + "name": name, + "version": version, + "description": description, + "previousComponentId": previous_component_id, + "slug": slug, + } + ) + return await self._api.send("POST", "/components", Component, body=body) + + async def update( + self, + component_slug: str, + *, + name: str, + version: str, + description: str | None = None, + ) -> Component: + """Replace a component's name, version and description, and return it. + + A full replacement: a description left out is cleared. Neither the slug nor the + previous component can be changed. + """ + body = compact({"name": name, "version": version, "description": description}) + return await self._api.send( + "PUT", api_path("components", component_slug), Component, body=body + ) + + async def delete(self, component_slug: str) -> None: + """Delete the component identified by ``component_slug``.""" + await self._api.send_empty("DELETE", api_path("components", component_slug)) diff --git a/src/kaitencloud/_async/resources/connectors.py b/src/kaitencloud/_async/resources/connectors.py new file mode 100644 index 0000000..dbefae7 --- /dev/null +++ b/src/kaitencloud/_async/resources/connectors.py @@ -0,0 +1,92 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from ..._utils import api_path +from ...types import ( + Connector, + ConnectorActivation, + ConnectorSettings, + ConnectorSettingsSchema, + ConnectorState, +) +from ._base import AsyncResource + +__all__ = ["AsyncConnectors"] + + +class AsyncConnectors(AsyncResource): + """Connectors: the integrations this deployment offers, and this organization's use of them. + + Registering a connector is deployment-wide and belongs to the Platform API + (``KaitenPlatformClient.connectors``). Activating and configuring one is each + organization's own decision, made here. + """ + + async def list(self) -> builtins.list[Connector]: + """Return every connector registered with this deployment.""" + return await self._api.get_list("/connectors", Connector) + + async def get(self, connector_name: str) -> Connector: + """Return the connector registered as ``connector_name``.""" + return await self._api.get(api_path("connectors", connector_name), Connector) + + async def get_state(self, connector_name: str) -> ConnectorState: + """Return whether the connector is available, licensed, and activated for this organization. + + Three separate answers, where a single 404 could never tell "not registered here" from + "not in your license" from "not turned on". + """ + return await self._api.get(api_path("connectors", connector_name, "state"), ConnectorState) + + async def activate(self, connector_name: str) -> ConnectorActivation: + """Activate the connector for this organization. + + Refused when the connector is gated by a BOOLEAN entitlement this organization's + license does not grant. + """ + return await self._api.send( + "PUT", api_path("connectors", connector_name, "activation"), ConnectorActivation + ) + + async def deactivate(self, connector_name: str) -> None: + """Deactivate the connector for this organization.""" + await self._api.send_empty("DELETE", api_path("connectors", connector_name, "activation")) + + async def get_settings_schema(self, connector_name: str) -> ConnectorSettingsSchema: + """Return the JSON schema the connector's settings are validated against.""" + return await self._api.get( + api_path("connectors", connector_name, "settings", "schema"), ConnectorSettingsSchema + ) + + async def get_settings(self, connector_name: str) -> ConnectorSettings: + """Return this organization's settings for the connector. + + Secrets -- the ``writeOnly`` properties of the settings schema -- are never read back. + """ + return await self._api.get( + api_path("connectors", connector_name, "settings"), ConnectorSettings + ) + + async def update_settings( + self, connector_name: str, settings: Mapping[str, Any] + ) -> ConnectorSettings: + """Replace this organization's settings for the connector, and return them. + + The settings are validated against the connector's settings schema. + """ + return await self._api.send( + "PUT", + api_path("connectors", connector_name, "settings"), + ConnectorSettings, + body={"settings": dict(settings)}, + ) + + async def delete_settings(self, connector_name: str) -> None: + """Delete this organization's settings for the connector.""" + await self._api.send_empty("DELETE", api_path("connectors", connector_name, "settings")) diff --git a/src/kaitencloud/_async/resources/customers.py b/src/kaitencloud/_async/resources/customers.py new file mode 100644 index 0000000..cacdfd0 --- /dev/null +++ b/src/kaitencloud/_async/resources/customers.py @@ -0,0 +1,144 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from ..._utils import api_path, compact, integration_body, integrations_body +from ...types import Customer, CustomerIntegration, IntegrationParam +from ._base import AsyncResource + +__all__ = ["AsyncCustomers"] + + +class AsyncCustomers(AsyncResource): + """Customers: the end clients of your product, each owning its instances.""" + + async def list(self) -> builtins.list[Customer]: + """Return every customer, walking all pages.""" + return await self._api.list_all("/customers", Customer) + + async def get(self, customer_slug: str) -> Customer: + """Return the customer identified by ``customer_slug``.""" + return await self._api.get(api_path("customers", customer_slug), Customer) + + async def create( + self, + *, + name: str, + slug: str | None = None, + external_customer_id: str | None = None, + domain: str | None = None, + integrations: Mapping[str, IntegrationParam] | None = None, + ) -> Customer: + """Create a customer. + + Args: + name: The customer's name. + slug: Its URL-friendly identifier, unique in the organization. Generated from the + name when omitted, and immutable once set. + external_customer_id: Its identifier in your own systems, e.g. your CRM. + domain: Its domain name, e.g. ``"acme.com"``. + integrations: Links to records in third-party systems, keyed by adapter name. + Only settable here: afterwards, use :meth:`update_integration`. + + Raises: + ConflictError: The slug is taken, or the organization reached its customer limit. + """ + body = compact( + { + "name": name, + "slug": slug, + "externalCustomerId": external_customer_id, + "domain": domain, + "integrations": integrations_body(integrations), + } + ) + return await self._api.send("POST", "/customers", Customer, body=body) + + async def update( + self, + customer_slug: str, + *, + name: str, + external_customer_id: str | None = None, + domain: str | None = None, + ) -> None: + """Replace a customer's details. + + A full replacement: the external customer id and the domain are cleared when left out. + The slug cannot be changed, and integrations have their own methods. + """ + body = compact({"name": name, "externalCustomerId": external_customer_id, "domain": domain}) + await self._api.send_empty("PUT", api_path("customers", customer_slug), body=body) + + async def delete(self, customer_slug: str) -> None: + """Delete the customer identified by ``customer_slug``.""" + await self._api.send_empty("DELETE", api_path("customers", customer_slug)) + + async def get_integration( + self, customer_slug: str, integration_name: str + ) -> CustomerIntegration: + """Return the customer's link to the ``integration_name`` adapter.""" + return await self._api.get( + api_path("customers", customer_slug, "integrations", integration_name), + CustomerIntegration, + ) + + async def create_integration( + self, + customer_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> CustomerIntegration: + """Link the customer to a record in the ``integration_name`` adapter. + + Args: + customer_slug: The customer to link. + integration_name: The adapter, e.g. ``"attio"``. + external_id: The record's identifier in the third-party system. + metadata: Adapter-specific metadata. + web_url: An absolute http(s) link to the record. + last_error: The last synchronization error, if any. + """ + return await self._api.send( + "POST", + api_path("customers", customer_slug, "integrations", integration_name), + CustomerIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + async def update_integration( + self, + customer_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> CustomerIntegration: + """Replace the customer's link to the ``integration_name`` adapter, and return it.""" + return await self._api.send( + "PUT", + api_path("customers", customer_slug, "integrations", integration_name), + CustomerIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + async def delete_integration(self, customer_slug: str, integration_name: str) -> None: + """Remove the customer's link to the ``integration_name`` adapter.""" + await self._api.send_empty( + "DELETE", api_path("customers", customer_slug, "integrations", integration_name) + ) diff --git a/src/kaitencloud/_async/resources/deployment_zones.py b/src/kaitencloud/_async/resources/deployment_zones.py new file mode 100644 index 0000000..5f2f0c7 --- /dev/null +++ b/src/kaitencloud/_async/resources/deployment_zones.py @@ -0,0 +1,95 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from ..._utils import api_path, compact +from ...types import DeploymentZone +from ._base import AsyncResource + +__all__ = ["AsyncDeploymentZones"] + + +class AsyncDeploymentZones(AsyncResource): + """Deployment zones: the environments your instances run in, and the release on each.""" + + async def list(self) -> builtins.list[DeploymentZone]: + """Return every deployment zone, walking all pages.""" + return await self._api.list_all("/deployment-zones", DeploymentZone) + + async def get(self, deployment_zone_slug: str) -> DeploymentZone: + """Return the deployment zone identified by ``deployment_zone_slug``.""" + return await self._api.get( + api_path("deployment-zones", deployment_zone_slug), DeploymentZone + ) + + async def create( + self, + *, + name: str, + type: str, + description: str = "", + metadata: Mapping[str, Any] | None = None, + release_id: str | None = None, + slug: str | None = None, + ) -> DeploymentZone: + """Create a deployment zone. + + Args: + name: The zone's name, e.g. ``"AWS eu-west-1"``. + type: Its environment class. Free-form, but ``production``, ``staging`` and + ``development`` are the ones the console labels. + description: A description. + metadata: Typed metadata, validated against the organization's ``DEPLOYMENT_ZONE`` + metadata fields. + release_id: The id of the release currently deployed to the zone. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = compact( + { + "name": name, + "type": type, + "description": description, + "metadata": dict(metadata) if metadata is not None else None, + "releaseId": release_id, + "slug": slug, + } + ) + return await self._api.send("POST", "/deployment-zones", DeploymentZone, body=body) + + async def update( + self, + deployment_zone_slug: str, + *, + name: str, + type: str, + description: str = "", + metadata: Mapping[str, Any] | None = None, + release_id: str | None = None, + ) -> None: + """Replace a deployment zone's details. + + A full replacement: send back the metadata and release to keep them. Setting a new + ``release_id`` records a deployment of that release to the zone. The slug cannot be + changed. + """ + body = compact( + { + "name": name, + "type": type, + "description": description, + "metadata": dict(metadata) if metadata is not None else None, + "releaseId": release_id, + } + ) + await self._api.send_empty( + "PUT", api_path("deployment-zones", deployment_zone_slug), body=body + ) + + async def delete(self, deployment_zone_slug: str) -> None: + """Delete the deployment zone identified by ``deployment_zone_slug``.""" + await self._api.send_empty("DELETE", api_path("deployment-zones", deployment_zone_slug)) diff --git a/src/kaitencloud/_async/resources/entitlement_groups.py b/src/kaitencloud/_async/resources/entitlement_groups.py new file mode 100644 index 0000000..aef3582 --- /dev/null +++ b/src/kaitencloud/_async/resources/entitlement_groups.py @@ -0,0 +1,89 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins + +from ..._utils import api_path, compact +from ...types import EntitlementGroup, EntitlementGroupUsage +from ._base import AsyncResource + +__all__ = ["AsyncEntitlementGroups"] + + +class AsyncEntitlementGroups(AsyncResource): + """Entitlement groups: named sets of entitlements, for display and for shared quotas.""" + + async def list(self) -> builtins.list[EntitlementGroup]: + """Return every entitlement group, walking all pages.""" + return await self._api.list_all("/entitlement-groups", EntitlementGroup) + + async def get(self, entitlement_group_slug: str) -> EntitlementGroup: + """Return the entitlement group identified by ``entitlement_group_slug``.""" + return await self._api.get( + api_path("entitlement-groups", entitlement_group_slug), EntitlementGroup + ) + + async def create( + self, + *, + name: str, + description: str | None = None, + slug: str | None = None, + ) -> EntitlementGroup: + """Create an entitlement group. + + Args: + name: The group's name, e.g. ``"AI quotas"``. + description: A description. Always sent -- as null when omitted -- because the + API requires the key. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = {"name": name, "description": description, **compact({"slug": slug})} + return await self._api.send("POST", "/entitlement-groups", EntitlementGroup, body=body) + + async def update( + self, + entitlement_group_slug: str, + *, + name: str, + description: str | None = None, + ) -> None: + """Replace an entitlement group's name and description. The slug cannot be changed.""" + await self._api.send_empty( + "PUT", + api_path("entitlement-groups", entitlement_group_slug), + body={"name": name, "description": description}, + ) + + async def delete(self, entitlement_group_slug: str) -> None: + """Delete the entitlement group identified by ``entitlement_group_slug``.""" + await self._api.send_empty("DELETE", api_path("entitlement-groups", entitlement_group_slug)) + + async def add_entitlement(self, entitlement_group_slug: str, entitlement_slug: str) -> None: + """Add the entitlement identified by ``entitlement_slug`` to the group.""" + await self._api.send_empty( + "POST", + api_path("entitlement-groups", entitlement_group_slug, "entitlements"), + body={"entitlementSlug": entitlement_slug}, + ) + + async def remove_entitlement(self, entitlement_group_slug: str, entitlement_slug: str) -> None: + """Remove the entitlement identified by ``entitlement_slug`` from the group.""" + await self._api.send_empty( + "DELETE", + api_path( + "entitlement-groups", entitlement_group_slug, "entitlements", entitlement_slug + ), + ) + + async def get_usage( + self, entitlement_group_slug: str, instance_slug: str + ) -> builtins.list[EntitlementGroupUsage]: + """Return the grant and the usage of each of the group's entitlements, for one instance.""" + return await self._api.get_list( + api_path("entitlement-groups", entitlement_group_slug, "usage"), + EntitlementGroupUsage, + params={"instance": instance_slug}, + ) diff --git a/src/kaitencloud/_async/resources/entitlements.py b/src/kaitencloud/_async/resources/entitlements.py new file mode 100644 index 0000000..b2cc8f4 --- /dev/null +++ b/src/kaitencloud/_async/resources/entitlements.py @@ -0,0 +1,202 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from typing import Any + +from ..._utils import api_path, compact +from ...types import AggregationMethod, Entitlement, EntitlementType, ResetAnchor, ResetPeriod +from ._base import AsyncResource + +__all__ = ["AsyncEntitlements"] + + +class AsyncEntitlements(AsyncResource): + """Entitlements: the catalog of what a license can grant, and how usage is measured. + + An entitlement says *what* can be measured. How *much* a license grants of it is set on + the license (``client.licenses.associate_entitlement``), and how much an instance has + consumed is reported on the instance (``client.instances.report_usage``). + """ + + async def list(self) -> builtins.list[Entitlement]: + """Return every entitlement, walking all pages.""" + return await self._api.list_all("/entitlements", Entitlement) + + async def get(self, entitlement_slug: str) -> Entitlement: + """Return the entitlement identified by ``entitlement_slug``.""" + return await self._api.get(api_path("entitlements", entitlement_slug), Entitlement) + + async def create( + self, + *, + name: str, + type: EntitlementType | None = None, + description: str | None = None, + slug: str | None = None, + aggregation_method: AggregationMethod | None = None, + reset_period: ResetPeriod | None = None, + reset_anchor: ResetAnchor | None = None, + warning_threshold_percent: int | None = None, + group_slugs: Sequence[str] | None = None, + user_facing: bool | None = None, + display_order: int | None = None, + icon: str | None = None, + unit_singular: str | None = None, + unit_plural: str | None = None, + sale_unit_singular: str | None = None, + sale_unit_plural: str | None = None, + sale_unit_factor: float | None = None, + ) -> Entitlement: + """Create an entitlement. + + Args: + name: The entitlement's name, e.g. ``"Seats"``. + type: ``BOOLEAN`` (a feature), ``NUMBER`` or ``NUMBER_AI_CREDIT`` (a metered quota) + or ``CONFIG`` (a configuration value). + description: A description. Always sent -- as null when omitted -- because the + API requires the key. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + aggregation_method: How each reported value folds into the total, for a NUMBER + entitlement: ``SUM`` (the default), ``COUNT``, ``AVERAGE``, ``MIN``, ``MAX`` or + ``LATEST``. + reset_period: How often usage resets (``HOUR`` to ``YEAR``). Omitted, usage is a + lifetime counter. Once set it can never be changed or removed. + reset_anchor: What the reset windows align on: ``CALENDAR`` (the default) or + ``LICENSE_START``. Immutable once set. + warning_threshold_percent: The share of the cap, from 0 to 100, at which a warning + event fires. 0 disables it. + group_slugs: The groups the entitlement belongs to. + user_facing: Whether customer-facing components (pricing tables, usage meters) + show it. Defaults to ``False``, which keeps internal counters out of them. + display_order: Its position in customer-facing components, ascending. + icon: A provider-namespaced icon token, e.g. ``"lucide:users"``. + unit_singular: The base unit's singular label, e.g. ``"seat"``. Set both labels or + neither. + unit_plural: The base unit's plural label, e.g. ``"seats"``. + sale_unit_singular: The label of the unit it is sold in, when that differs from the + base unit, e.g. ``"pack"``. Requires the base unit labels and the full sale + unit trio. + sale_unit_plural: The sale unit's plural label. + sale_unit_factor: How many base units one sale unit holds, e.g. ``1_000_000``. + """ + body = { + "name": name, + "description": description, + **compact( + { + "slug": slug, + **_fields( + type=type, + aggregation_method=aggregation_method, + reset_period=reset_period, + reset_anchor=reset_anchor, + warning_threshold_percent=warning_threshold_percent, + group_slugs=group_slugs, + user_facing=user_facing, + display_order=display_order, + icon=icon, + unit_singular=unit_singular, + unit_plural=unit_plural, + sale_unit_singular=sale_unit_singular, + sale_unit_plural=sale_unit_plural, + sale_unit_factor=sale_unit_factor, + ), + } + ), + } + return await self._api.send("POST", "/entitlements", Entitlement, body=body) + + async def update( + self, + entitlement_slug: str, + *, + name: str, + type: EntitlementType | None = None, + description: str | None = None, + aggregation_method: AggregationMethod | None = None, + reset_period: ResetPeriod | None = None, + reset_anchor: ResetAnchor | None = None, + warning_threshold_percent: int | None = None, + group_slugs: Sequence[str] | None = None, + user_facing: bool | None = None, + display_order: int | None = None, + icon: str | None = None, + unit_singular: str | None = None, + unit_plural: str | None = None, + sale_unit_singular: str | None = None, + sale_unit_plural: str | None = None, + sale_unit_factor: float | None = None, + ) -> None: + """Replace an entitlement's definition. + + A full replacement: optional fields left out are reset. ``type``, + ``aggregation_method``, ``reset_period`` and ``reset_anchor`` are immutable once set -- + send the stored values back, as the API refuses a different one. The slug cannot be + changed. See :meth:`create` for what each field means. + """ + body = { + "name": name, + "description": description, + **compact( + _fields( + type=type, + aggregation_method=aggregation_method, + reset_period=reset_period, + reset_anchor=reset_anchor, + warning_threshold_percent=warning_threshold_percent, + group_slugs=group_slugs, + user_facing=user_facing, + display_order=display_order, + icon=icon, + unit_singular=unit_singular, + unit_plural=unit_plural, + sale_unit_singular=sale_unit_singular, + sale_unit_plural=sale_unit_plural, + sale_unit_factor=sale_unit_factor, + ) + ), + } + await self._api.send_empty("PUT", api_path("entitlements", entitlement_slug), body=body) + + async def delete(self, entitlement_slug: str) -> None: + """Delete the entitlement identified by ``entitlement_slug``.""" + await self._api.send_empty("DELETE", api_path("entitlements", entitlement_slug)) + + +def _fields( + *, + type: EntitlementType | None, + aggregation_method: AggregationMethod | None, + reset_period: ResetPeriod | None, + reset_anchor: ResetAnchor | None, + warning_threshold_percent: int | None, + group_slugs: Sequence[str] | None, + user_facing: bool | None, + display_order: int | None, + icon: str | None, + unit_singular: str | None, + unit_plural: str | None, + sale_unit_singular: str | None, + sale_unit_plural: str | None, + sale_unit_factor: float | None, +) -> dict[str, Any]: + return { + "type": type, + "aggregationMethod": aggregation_method, + "resetPeriod": reset_period, + "resetAnchor": reset_anchor, + "warningThresholdPercent": warning_threshold_percent, + "groupSlugs": list(group_slugs) if group_slugs is not None else None, + "userFacing": user_facing, + "displayOrder": display_order, + "icon": icon, + "unitSingular": unit_singular, + "unitPlural": unit_plural, + "saleUnitSingular": sale_unit_singular, + "saleUnitPlural": sale_unit_plural, + "saleUnitFactor": sale_unit_factor, + } diff --git a/src/kaitencloud/_async/resources/feature_flags.py b/src/kaitencloud/_async/resources/feature_flags.py new file mode 100644 index 0000000..e6efab5 --- /dev/null +++ b/src/kaitencloud/_async/resources/feature_flags.py @@ -0,0 +1,164 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from ..._utils import api_path +from ...types import ( + DefaultVariantParam, + FeatureFlag, + FeatureFlagType, + ManifestEnvelope, + ManifestFlag, + TargetingParam, + VariantParam, +) +from ._base import AsyncResource + +__all__ = ["AsyncFeatureFlags"] + + +class AsyncFeatureFlags(AsyncResource): + """Feature flags: variants, targeting rules and default variants. + + This namespace manages flag definitions. To evaluate a flag for a context, use + ``client.flags``. The builders in :mod:`kaitencloud.targeting` write variants and rules. + """ + + async def list(self) -> builtins.list[FeatureFlag]: + """Return every feature flag, walking all pages.""" + return await self._api.list_all("/feature-flags", FeatureFlag) + + async def get(self, feature_flag_slug: str) -> FeatureFlag: + """Return the feature flag identified by ``feature_flag_slug``.""" + return await self._api.get(api_path("feature-flags", feature_flag_slug), FeatureFlag) + + async def create( + self, + *, + name: str, + type: FeatureFlagType, + variants: Sequence[VariantParam], + default_variant: str | DefaultVariantParam, + targetings: Sequence[TargetingParam] = (), + enabled: bool = True, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + event_name: str = "", + slug: str | None = None, + ) -> FeatureFlag: + """Create a feature flag. + + Args: + name: The flag's name. + type: The type of its variants' values: ``boolean``, ``string``, ``number`` or + ``object``. + variants: Its possible values, e.g. ``[targeting.variant("on", True), + targeting.variant("off", False)]``. + default_variant: What it serves when no rule matches: a variant name, or a + rollout from :func:`kaitencloud.targeting.split` or + :func:`kaitencloud.targeting.schedule`. + targetings: Its targeting rules, evaluated in order; the first match wins. + enabled: The kill switch. A disabled flag serves its default variant to everyone + and evaluates no rule. + description: A description. + metadata: Free-form metadata. + event_name: The analytics event associated with the flag, if any. + slug: Its URL-friendly identifier, generated when omitted. Doubles as the key the + flag is evaluated by. + """ + body = _flag( + name=name, + type=type, + variants=variants, + default_variant=default_variant, + targetings=targetings, + enabled=enabled, + description=description, + metadata=metadata, + event_name=event_name, + ) + if slug is not None: + body["slug"] = slug + return await self._api.send("POST", "/feature-flags", FeatureFlag, body=body) + + async def update( + self, + feature_flag_slug: str, + *, + name: str, + type: FeatureFlagType, + variants: Sequence[VariantParam], + default_variant: str | DefaultVariantParam, + targetings: Sequence[TargetingParam] = (), + enabled: bool = True, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + event_name: str = "", + ) -> None: + """Replace a feature flag's definition. + + A full replacement: the variants, rules and metadata sent become the flag's whole + definition. See :meth:`create` for what each field means. + """ + body = _flag( + name=name, + type=type, + variants=variants, + default_variant=default_variant, + targetings=targetings, + enabled=enabled, + description=description, + metadata=metadata, + event_name=event_name, + ) + await self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body) + + async def delete(self, feature_flag_slug: str) -> None: + """Delete the feature flag identified by ``feature_flag_slug``.""" + await self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug)) + + async def manifest(self) -> builtins.list[ManifestFlag]: + """Return the OpenFeature manifest: every flag with its type and default value.""" + envelope = await self._api.get("/openfeature/v0/manifest", ManifestEnvelope) + return builtins.list(envelope.flags or []) + + +def _flag( + *, + name: str, + type: FeatureFlagType, + variants: Sequence[VariantParam], + default_variant: str | DefaultVariantParam, + targetings: Sequence[TargetingParam], + enabled: bool, + description: str | None, + metadata: Mapping[str, Any] | None, + event_name: str, +) -> dict[str, Any]: + return { + "name": name, + "type": type, + "variants": [ + { + "name": variant["name"], + "description": variant.get("description", ""), + "value": variant["value"], + } + for variant in variants + ], + "default_variant": ( + {"type": "basic", "value": default_variant} + if isinstance(default_variant, str) + else dict(default_variant) + ), + "targetings": [dict(targeting) for targeting in targetings], + "enabled": enabled, + "description": description, + "metadata": dict(metadata) if metadata is not None else {}, + "event_name": event_name, + } diff --git a/src/kaitencloud/_async/resources/instances.py b/src/kaitencloud/_async/resources/instances.py new file mode 100644 index 0000000..d7b6906 --- /dev/null +++ b/src/kaitencloud/_async/resources/instances.py @@ -0,0 +1,349 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from ..._exceptions import ThresholdExceededError +from ..._utils import ( + api_path, + compact, + format_datetime, + integration_body, + integrations_body, + optional_datetime, +) +from ...types import ( + AuditTrail, + EntitlementUsage, + Instance, + InstanceIntegration, + InstanceStatus, + IntegrationParam, + UsageBehavior, +) +from ._base import AsyncResource + +__all__ = ["AsyncInstances"] + + +class AsyncInstances(AsyncResource): + """Instances: deployments of your product for a customer, under a license. + + An instance is where entitlements meet reality: usage is reported against it, and its + audit trail records what happened to it. + """ + + async def list(self) -> builtins.list[Instance]: + """Return every instance, walking all pages.""" + return await self._api.list_all("/instances", Instance) + + async def get(self, instance_slug: str) -> Instance: + """Return the instance identified by ``instance_slug``.""" + return await self._api.get(api_path("instances", instance_slug), Instance) + + async def create( + self, + *, + name: str, + customer_id: str, + license_id: str, + start_license_date: datetime | str, + end_license_date: datetime | str, + description: str = "", + deployment_zone_id: str | None = None, + metadata: Mapping[str, Any] | None = None, + slug: str | None = None, + integrations: Mapping[str, IntegrationParam] | None = None, + ) -> Instance: + """Create an instance. + + Args: + name: The instance's name. + customer_id: The id -- not the slug -- of the customer it is deployed for. + license_id: The id of the license it runs under. + start_license_date: When its license term starts: a timezone-aware datetime, or an + RFC 3339 string. + end_license_date: When its license term ends. + description: A description. + deployment_zone_id: The id of the deployment zone it runs in. + metadata: Typed metadata, validated against the organization's ``INSTANCE`` + metadata fields. + slug: Its URL-friendly identifier, generated when omitted. + integrations: Links to records in third-party systems, keyed by adapter name. + Only settable here: afterwards, use :meth:`update_integration`. + + Raises: + NotFoundError: The customer, license or deployment zone does not exist in this + organization. + ConflictError: The slug is taken, or the organization reached its instance limit. + """ + body = { + **_instance( + name=name, + customer_id=customer_id, + license_id=license_id, + start_license_date=start_license_date, + end_license_date=end_license_date, + description=description, + deployment_zone_id=deployment_zone_id, + metadata=metadata, + ), + **compact({"slug": slug, "integrations": integrations_body(integrations)}), + } + return await self._api.send("POST", "/instances", Instance, body=body) + + async def update( + self, + instance_slug: str, + *, + name: str, + customer_id: str, + license_id: str, + start_license_date: datetime | str, + end_license_date: datetime | str, + description: str = "", + deployment_zone_id: str | None = None, + metadata: Mapping[str, Any] | None = None, + slug: str | None = None, + ) -> None: + """Replace an instance's details. + + A full replacement, with two fields where leaving a value out keeps the current one: + ``deployment_zone_id`` and ``slug``. Passing a ``slug`` renames the instance. + ``metadata`` replaces the whole blob, so send back the keys to keep. + + Moving an instance into a deployment zone, or to another one, is how a deployment or a + migration is recorded: there is no separate call for either. + """ + body = { + **_instance( + name=name, + customer_id=customer_id, + license_id=license_id, + start_license_date=start_license_date, + end_license_date=end_license_date, + description=description, + deployment_zone_id=deployment_zone_id, + metadata=metadata, + ), + **compact({"slug": slug}), + } + await self._api.send_empty("PUT", api_path("instances", instance_slug), body=body) + + async def update_status(self, instance_slug: str, status: InstanceStatus) -> None: + """Set an instance's operational status, leaving everything else untouched. + + ``status`` is ``HEALTHY``, ``DEGRADED``, ``INCIDENT`` or ``MAINTENANCE``. + """ + await self._api.send_empty( + "PATCH", api_path("instances", instance_slug), body={"status": status}, idempotent=True + ) + + async def update_lifecycle_stage(self, instance_slug: str, lifecycle_stage: str) -> None: + """Set an instance's commercial lifecycle stage, leaving everything else untouched. + + Free-form; ``TRIAL``, ``ACTIVE``, ``AT_RISK`` and ``CHURNED`` are the suggested stages. + """ + await self._api.send_empty( + "PATCH", + api_path("instances", instance_slug), + body={"lifecycleStage": lifecycle_stage}, + idempotent=True, + ) + + async def delete(self, instance_slug: str) -> None: + """Delete the instance identified by ``instance_slug``. The deletion is permanent.""" + await self._api.send_empty("DELETE", api_path("instances", instance_slug)) + + async def list_audit_trails( + self, + instance_slug: str, + *, + event_name: str | None = None, + after: datetime | str | None = None, + before: datetime | str | None = None, + limit: int | None = None, + ) -> builtins.list[AuditTrail]: + """Return an instance's audit trail entries. + + The audit trail grows without bound, so ``limit`` is a ceiling rather than a page + size: pages are walked until that many entries are held, and no page asks for more + than are still wanted. Without a limit, every entry is returned. + + Args: + instance_slug: The instance whose audit trail to read. + event_name: Only entries of this event, e.g. ``"INSTANCE_STATUS_CHANGED"``. + after: Only entries at or after this moment. + before: Only entries at or before this moment. + limit: The most entries to return. + """ + if limit is not None and limit < 1: + raise ValueError(f"limit must be at least 1, got {limit}") + return await self._api.list_all( + api_path("instances", instance_slug, "audit-trails"), + AuditTrail, + params={ + "event_name": event_name, + "after": optional_datetime(after), + "before": optional_datetime(before), + }, + limit=limit, + ) + + async def list_usage(self, instance_slug: str) -> builtins.list[EntitlementUsage]: + """Return the instance's usage of every entitlement, each next to the grant it counts against.""" + return await self._api.get_list( + api_path("instances", instance_slug, "entitlements", "usage"), EntitlementUsage + ) + + async def get_usage(self, instance_slug: str, entitlement_slug: str) -> EntitlementUsage: + """Return the instance's usage of one entitlement, next to the grant it counts against. + + For a periodic entitlement, ``current_period_start`` and ``current_period_end`` bound + the window the usage belongs to; both are ``None`` for a lifetime counter. Each read is + recorded as an ``ENTITLEMENT_VALUE_GET`` event. + """ + return await self._api.get( + api_path("instances", instance_slug, "entitlements", entitlement_slug, "usage"), + EntitlementUsage, + ) + + async def report_usage( + self, + instance_slug: str, + entitlement_slug: str, + value: float, + *, + behavior: UsageBehavior = "append", + metadata: Mapping[str, Any] | None = None, + ) -> EntitlementUsage: + """Report usage of a NUMBER entitlement, and return the usage it results in. + + Args: + instance_slug: The instance that consumed. + entitlement_slug: The entitlement consumed. + value: The amount. With ``behavior="append"`` it is folded into the stored total + through the entitlement's aggregation method (a sum, by default). With + ``"set"`` it replaces the total -- which is also how to correct it downwards. + behavior: ``"append"`` (the default) or ``"set"``. + metadata: Free-form metadata recorded with the report. + + Raises: + ThresholdExceededError: The report would take usage past the maximum the license + allows -- the granted value plus its overage allowance. Nothing was recorded. + + Never retried automatically: without an idempotency key on the wire, a report retried + after a lost response would be counted twice. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"usage must be a number, got {type(value).__name__}") + body = compact( + { + "value": {"type": "number", "value": value}, + "behavior": behavior, + "metadata": dict(metadata) if metadata is not None else None, + } + ) + return await self._api.send( + "POST", + api_path("instances", instance_slug, "entitlements", entitlement_slug, "usage"), + EntitlementUsage, + body=body, + errors={409: ThresholdExceededError}, + ) + + async def get_integration( + self, instance_slug: str, integration_name: str + ) -> InstanceIntegration: + """Return the instance's link to the ``integration_name`` adapter.""" + return await self._api.get( + api_path("instances", instance_slug, "integrations", integration_name), + InstanceIntegration, + ) + + async def create_integration( + self, + instance_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> InstanceIntegration: + """Link the instance to a record in the ``integration_name`` adapter. + + Args: + instance_slug: The instance to link. + integration_name: The adapter, e.g. ``"attio"``. + external_id: The record's identifier in the third-party system. + metadata: Adapter-specific metadata. + web_url: An absolute http(s) link to the record. + last_error: The last synchronization error, if any. + """ + return await self._api.send( + "POST", + api_path("instances", instance_slug, "integrations", integration_name), + InstanceIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + async def update_integration( + self, + instance_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> InstanceIntegration: + """Replace the instance's link to the ``integration_name`` adapter, and return it.""" + return await self._api.send( + "PUT", + api_path("instances", instance_slug, "integrations", integration_name), + InstanceIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + async def delete_integration(self, instance_slug: str, integration_name: str) -> None: + """Remove the instance's link to the ``integration_name`` adapter.""" + await self._api.send_empty( + "DELETE", api_path("instances", instance_slug, "integrations", integration_name) + ) + + +def _instance( + *, + name: str, + customer_id: str, + license_id: str, + start_license_date: datetime | str, + end_license_date: datetime | str, + description: str, + deployment_zone_id: str | None, + metadata: Mapping[str, Any] | None, +) -> dict[str, Any]: + return { + "name": name, + "description": description, + "customerId": customer_id, + "licenseId": license_id, + "startLicenseDate": format_datetime(start_license_date), + "endLicenseDate": format_datetime(end_license_date), + **compact( + { + "deploymentZoneId": deployment_zone_id, + "metadata": dict(metadata) if metadata is not None else None, + } + ), + } diff --git a/src/kaitencloud/_async/resources/integrations.py b/src/kaitencloud/_async/resources/integrations.py new file mode 100644 index 0000000..4115a22 --- /dev/null +++ b/src/kaitencloud/_async/resources/integrations.py @@ -0,0 +1,143 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from ..._utils import api_path, compact, optional_datetime +from ...types import CustomerIntegrationResource, InstanceIntegrationResource +from ._base import AsyncResource + +__all__ = ["AsyncIntegrations"] + + +class AsyncIntegrations(AsyncResource): + """Customers and instances, addressed by their identifier in a third-party system. + + This is the surface a connector synchronizes through: it knows a record's id in *its* + system -- an Attio or HubSpot record -- not the Kaiten slug. ``adapter`` names that system, + without the ``kaiten.integration.`` prefix. + """ + + async def get_customer(self, adapter: str, external_id: str) -> CustomerIntegrationResource: + """Return the customer linked to ``external_id`` in ``adapter``.""" + return await self._api.get( + api_path("integration", adapter, "customer", external_id), CustomerIntegrationResource + ) + + async def upsert_customer( + self, + adapter: str, + external_id: str, + *, + name: str | None = None, + slug: str | None = None, + domain: str | None = None, + web_url: str | None = None, + error: str | None = None, + integration_metadata: Mapping[str, Any] | None = None, + ) -> CustomerIntegrationResource: + """Create or update the customer linked to ``external_id`` in ``adapter``. + + The customer is created -- subject to the organization's customer limit -- when no + customer is linked to that record yet, and updated otherwise. + + Args: + adapter: The third-party system, e.g. ``"attio"``. + external_id: The record's identifier in that system. + name: The customer's name. + slug: The customer's slug. + domain: The customer's domain name. + web_url: An absolute http(s) link to the record. + error: The synchronization error to record, if any. + integration_metadata: Adapter-specific metadata. + """ + body = compact( + { + "name": name, + "slug": slug, + "domain": domain, + "webUrl": web_url, + "error": error, + "integrationMetadata": ( + dict(integration_metadata) if integration_metadata is not None else None + ), + } + ) + return await self._api.send( + "PATCH", + api_path("integration", adapter, "customer", external_id), + CustomerIntegrationResource, + body=body, + ) + + async def get_instance(self, adapter: str, external_id: str) -> InstanceIntegrationResource: + """Return the instance linked to ``external_id`` in ``adapter``.""" + return await self._api.get( + api_path("integration", adapter, "instance", external_id), InstanceIntegrationResource + ) + + async def upsert_instance( + self, + adapter: str, + external_id: str, + *, + name: str | None = None, + slug: str | None = None, + description: str | None = None, + customer_external_id: str | None = None, + license_id: str | None = None, + deployment_zone_id: str | None = None, + start_license_date: datetime | str | None = None, + end_license_date: datetime | str | None = None, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + error: str | None = None, + integration_metadata: Mapping[str, Any] | None = None, + ) -> InstanceIntegrationResource: + """Create or update the instance linked to ``external_id`` in ``adapter``. + + Args: + adapter: The third-party system, e.g. ``"attio"``. + external_id: The record's identifier in that system. + name: The instance's name. + slug: The instance's slug. + description: The instance's description. + customer_external_id: The identifier, in the same system, of the customer the + instance belongs to. + license_id: The id of the license the instance runs under. + deployment_zone_id: The id of the deployment zone it runs in. + start_license_date: When its license term starts. + end_license_date: When its license term ends. + metadata: The instance's metadata. + web_url: An absolute http(s) link to the record. + error: The synchronization error to record, if any. + integration_metadata: Adapter-specific metadata. + """ + body = compact( + { + "name": name, + "slug": slug, + "description": description, + "customerExternalId": customer_external_id, + "licenseId": license_id, + "deploymentZoneId": deployment_zone_id, + "startLicenseDate": optional_datetime(start_license_date), + "endLicenseDate": optional_datetime(end_license_date), + "metadata": dict(metadata) if metadata is not None else None, + "webUrl": web_url, + "error": error, + "integrationMetadata": ( + dict(integration_metadata) if integration_metadata is not None else None + ), + } + ) + return await self._api.send( + "PATCH", + api_path("integration", adapter, "instance", external_id), + InstanceIntegrationResource, + body=body, + ) diff --git a/src/kaitencloud/_async/resources/license_families.py b/src/kaitencloud/_async/resources/license_families.py new file mode 100644 index 0000000..c71485f --- /dev/null +++ b/src/kaitencloud/_async/resources/license_families.py @@ -0,0 +1,61 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from typing import Any + +from ..._utils import api_path +from ...types import LicenseFamilyView +from ._base import AsyncResource + +__all__ = ["AsyncLicenseFamilies"] + + +class AsyncLicenseFamilies(AsyncResource): + """License families: the products whose versions :attr:`KaitenClient.licenses` are. + + A family's slug survives renames and new versions, so it is the identifier worth storing: + a version's slug names the row a customer bought, and a new one appears every time a + version is published. Reading a family resolves it to the version it currently serves. + """ + + async def list(self) -> builtins.list[LicenseFamilyView]: + """Return every license family, each with the version it currently serves. + + A family with nothing published -- every version a draft, or all of them archived -- + is listed too, with ``current_version`` set to ``None``. + """ + return await self._api.list_all("/license-families", LicenseFamilyView) + + async def get( + self, family_slug: str, *, version: int | None = None, include_versions: bool = False + ) -> LicenseFamilyView: + """Resolve the family identified by ``family_slug`` to one of its versions. + + With no options it returns, as ``current_version``, the version the family serves + today: its default version, or failing that its highest-numbered published one. That + is the point of addressing a family -- publishing a new version changes what this + returns, with no change to the caller. + + Args: + family_slug: The family's slug. + version: Read this version number instead, whatever its lifecycle state, archived + included: pinned access stays pinned. + include_versions: Also return every version of the family, oldest first, as + ``versions``. It makes a family with no published version readable too. + + Raises: + NotFoundError: No family has this slug -- or it exists but has no published + version and ``include_versions`` was not asked, which the problem's ``code`` + tells apart: ``GetLicenseFamily.NoPublishedVersion``. + """ + params: dict[str, Any] = {} + if version is not None: + params["version"] = version + if include_versions: + params["include"] = "versions" + return await self._api.get( + api_path("license-families", family_slug), LicenseFamilyView, params=params or None + ) diff --git a/src/kaitencloud/_async/resources/licenses.py b/src/kaitencloud/_async/resources/licenses.py new file mode 100644 index 0000000..2e49181 --- /dev/null +++ b/src/kaitencloud/_async/resources/licenses.py @@ -0,0 +1,247 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from typing import Literal + +from ..._types import EntitlementValueInput +from ..._utils import api_path, compact, entitlement_value +from ...types import License, LicenseEntitlement, LicenseType +from ._base import AsyncResource + +__all__ = ["AsyncLicenses"] + + +class AsyncLicenses(AsyncResource): + """Licenses: the versions of a license family, each granting values of catalog entitlements. + + A license is one version of a product, the *license family*. Creating a license without + naming a family opens a new one, of which it is version 1; naming a family with + ``family_slug`` or ``family_id`` adds that family's next version. Each version moves + through a lifecycle -- ``DRAFT``, ``PUBLISHED``, ``ARCHIVED`` -- by :meth:`publish`, + :meth:`archive` and :meth:`unarchive`, and :attr:`KaitenClient.license_families` resolves + a family to the version it currently serves. + """ + + async def list(self) -> builtins.list[License]: + """Return every license, walking all pages.""" + return await self._api.list_all("/licenses", License) + + async def get(self, license_slug: str) -> License: + """Return the license identified by ``license_slug``.""" + return await self._api.get(api_path("licenses", license_slug), License) + + async def create( + self, + *, + name: str, + type: LicenseType, + description: str = "", + is_default: bool = False, + version_name: str | None = None, + slug: str | None = None, + family_slug: str | None = None, + family_id: str | None = None, + lifecycle_state: Literal["DRAFT", "PUBLISHED"] | None = None, + ) -> License: + """Create a license: a new license family, or the next version of an existing one. + + Without ``family_slug`` or ``family_id`` this opens a new family, of which the license + is version 1, and the family takes the license's slug. With either, it adds the next + version to that family -- the version number is always assigned by the server, never + reused within a family, and cannot be sent. Two families may share a name: the family, + not the name, is the product. Where the deployment limits how many licenses an + organization may have, only a new family counts against that limit. + + Args: + name: The license's name, e.g. ``"Growth"``. + type: ``DEVELOPMENT``, ``TRIAL``, ``PAID`` or ``COMMUNITY``. + description: A description, up to 500 characters. + is_default: Whether this version is the one its family puts forward. Only a + ``PUBLISHED`` version can be. + version_name: A label for this version, e.g. ``"Winter 2026"``. Defaults to + ``"Version - "``. + slug: Its URL-friendly identifier. When omitted, a new version of an existing + family takes ``-v``, and a new family's first version one + generated from its name. Immutable once set. + family_slug: The family this license is a new version of, by slug. + family_id: The same family by identifier. Sent together, the two must agree. + lifecycle_state: ``DRAFT`` to create the version off sale, to be put on sale with + :meth:`publish`. ``PUBLISHED`` when omitted. A version is never created + ``ARCHIVED``: it is archived with :meth:`archive` once it has been on sale. + + Raises: + NotFoundError: ``family_slug`` or ``family_id`` names no family + (``CreateLicense.FamilyNotFound``). + ConflictError: ``is_default=True`` on a draft (``CreateLicense.DefaultMustBePublished``), + or the organization reached its license limit, which only a new family counts + against. + UnprocessableEntityError: ``family_slug`` and ``family_id`` name different families + (``CreateLicense.FamilyMismatch``). + """ + body = { + "name": name, + "description": description, + "type": type, + "isDefault": is_default, + **compact( + { + "versionName": version_name, + "slug": slug, + "familySlug": family_slug, + "familyId": family_id, + "lifecycleState": lifecycle_state, + } + ), + } + return await self._api.send("POST", "/licenses", License, body=body) + + async def update( + self, + license_slug: str, + *, + name: str, + type: LicenseType, + description: str = "", + is_default: bool = False, + version_name: str | None = None, + ) -> None: + """Replace a license version's details. + + A full replacement. The slug, the version number, the family and the lifecycle state + are not part of it: the first three never change, and the state moves only through + :meth:`publish`, :meth:`archive` and :meth:`unarchive`. ``is_default=True`` takes the + flag from the family's previous default, and ``False`` unsets it on this version. + + Raises: + ConflictError: ``is_default=True`` on a version that is not ``PUBLISHED`` + (``UpdateLicense.DefaultMustBePublished``): publish or unarchive it first. + """ + body = { + "name": name, + "description": description, + "type": type, + "isDefault": is_default, + **compact({"versionName": version_name}), + } + await self._api.send_empty("PUT", api_path("licenses", license_slug), body=body) + + async def publish(self, license_slug: str) -> License: + """Put a ``DRAFT`` version on sale, and return it ``PUBLISHED``. + + It can then be made its family's default, and a family without a default serves its + highest-numbered published version, which may be this one. + + Raises: + ConflictError: The version is not a draft (``PublishLicense.NotADraft``). An + archived version goes back on sale with :meth:`unarchive`. + """ + return await self._api.send("POST", api_path("licenses", license_slug, "publish"), License) + + async def archive(self, license_slug: str) -> License: + """Withdraw a ``PUBLISHED`` version from sale, and return it ``ARCHIVED``. + + Its family stops resolving to it and no instance can be assigned to it any more, while + the instances already on it keep it. + + Raises: + ConflictError: The version is not published (``ArchiveLicense.NotPublished``) -- a + draft that was never on sale is deleted instead -- or it is its family's + default (``ArchiveLicense.DefaultMustBePublished``): make another version the + default, or unset it, first. + """ + return await self._api.send("POST", api_path("licenses", license_slug, "archive"), License) + + async def unarchive(self, license_slug: str) -> License: + """Put an ``ARCHIVED`` version back on sale, and return it ``PUBLISHED``. + + Raises: + ConflictError: The version is not archived (``UnarchiveLicense.NotArchived``). A + draft goes on sale with :meth:`publish`. + """ + return await self._api.send( + "POST", api_path("licenses", license_slug, "unarchive"), License + ) + + async def delete(self, license_slug: str) -> None: + """Delete the license version identified by ``license_slug``. + + Deleting a family's last version deletes the family too, and frees its slug. A deleted + version's number is never reused by the family's next version. + """ + await self._api.send_empty("DELETE", api_path("licenses", license_slug)) + + async def list_entitlements(self, license_slug: str) -> builtins.list[LicenseEntitlement]: + """Return every entitlement the license grants, with its value, walking all pages.""" + return await self._api.list_all( + api_path("licenses", license_slug, "entitlements"), LicenseEntitlement + ) + + async def get_entitlement(self, license_slug: str, entitlement_slug: str) -> LicenseEntitlement: + """Return the value the license grants the entitlement identified by ``entitlement_slug``.""" + return await self._api.get( + api_path("licenses", license_slug, "entitlements", entitlement_slug), + LicenseEntitlement, + ) + + async def associate_entitlement( + self, + license_slug: str, + entitlement_slug: str, + value: EntitlementValueInput, + *, + overage_percent: int | None = None, + ) -> None: + """Grant an entitlement under the license. + + Args: + license_slug: The license that grants. + entitlement_slug: The entitlement granted. + value: What is granted: ``True`` or ``False`` for a BOOLEAN entitlement, a number + for a NUMBER one -- :data:`kaitencloud.UNLIMITED` for no cap -- and a mapping + for a CONFIG one. + overage_percent: How far usage may exceed a numeric value before reports are + refused: ``0`` makes a hard limit, ``10`` tolerates 10% over. It is ``-1`` + exactly when the value is unlimited. The server picks it when omitted. Sent as + ``limitCapExceededOveragePercent``. + + Raises: + ConflictError: The license already grants this entitlement. + """ + body = { + "entitlementSlug": entitlement_slug, + "value": entitlement_value(value), + **compact({"limitCapExceededOveragePercent": overage_percent}), + } + await self._api.send_empty( + "POST", api_path("licenses", license_slug, "entitlements"), body=body + ) + + async def update_entitlement( + self, + license_slug: str, + entitlement_slug: str, + value: EntitlementValueInput, + *, + overage_percent: int | None = None, + ) -> None: + """Change what the license grants an entitlement. + + A full replacement of the grant: see :meth:`associate_entitlement` for ``value`` and + ``overage_percent``. + """ + body = { + "value": entitlement_value(value), + **compact({"limitCapExceededOveragePercent": overage_percent}), + } + await self._api.send_empty( + "PUT", api_path("licenses", license_slug, "entitlements", entitlement_slug), body=body + ) + + async def delete_entitlement(self, license_slug: str, entitlement_slug: str) -> None: + """Stop granting the entitlement identified by ``entitlement_slug`` under the license.""" + await self._api.send_empty( + "DELETE", api_path("licenses", license_slug, "entitlements", entitlement_slug) + ) diff --git a/src/kaitencloud/_async/resources/metadata_fields.py b/src/kaitencloud/_async/resources/metadata_fields.py new file mode 100644 index 0000000..bcf2a2b --- /dev/null +++ b/src/kaitencloud/_async/resources/metadata_fields.py @@ -0,0 +1,115 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from ..._utils import api_path, compact +from ...types import MetadataField, MetadataFieldImpact, MetadataResourceType +from ._base import AsyncResource + +__all__ = ["AsyncMetadataFields"] + + +class AsyncMetadataFields(AsyncResource): + """Metadata fields: the typed fields your organization declares on instances and zones. + + Each field is a JSON Schema. The metadata of an instance or a deployment zone is validated + against the active fields declared for its resource type. + """ + + async def list(self, resource_type: MetadataResourceType) -> builtins.list[MetadataField]: + """Return the metadata fields declared for ``resource_type``, walking all pages.""" + return await self._api.list_all( + "/metadata-fields", MetadataField, params={"resourceType": resource_type} + ) + + async def create( + self, + *, + resource_type: MetadataResourceType, + key: str, + label: str, + json_schema: Mapping[str, Any], + display_order: int | None = None, + ) -> MetadataField: + """Declare a metadata field. + + Args: + resource_type: ``INSTANCE`` or ``DEPLOYMENT_ZONE``. Immutable. + key: The metadata key the field describes, e.g. ``"region"``. Unique among the + active fields of the resource type, and immutable. + label: The label the console shows. + json_schema: A JSON Schema 2020-12 document for the value, e.g. + ``{"type": "string", "enum": ["eu", "us"]}``. + display_order: Its position in the console. Afterwards only :meth:`reorder` + changes it. + """ + body = { + "resourceType": resource_type, + "key": key, + "label": label, + "jsonSchema": dict(json_schema), + **compact({"displayOrder": display_order}), + } + return await self._api.send("POST", "/metadata-fields", MetadataField, body=body) + + async def update( + self, + field_id: str, + *, + resource_type: MetadataResourceType, + key: str, + label: str, + json_schema: Mapping[str, Any], + ) -> MetadataField: + """Change a metadata field's label and schema, and return the field. + + ``resource_type`` and ``key`` are immutable: send the stored values back, as the API + requires them and refuses different ones. Preview what a stricter schema would + invalidate with :meth:`dry_run` first. + """ + body = { + "resourceType": resource_type, + "key": key, + "label": label, + "jsonSchema": dict(json_schema), + } + return await self._api.send( + "PATCH", api_path("metadata-fields", field_id), MetadataField, body=body + ) + + async def archive(self, field_id: str) -> MetadataField: + """Archive a metadata field, which frees its key for a new one.""" + return await self._api.send( + "POST", api_path("metadata-fields", field_id, "archive"), MetadataField + ) + + async def unarchive(self, field_id: str) -> MetadataField: + """Restore an archived metadata field.""" + return await self._api.send( + "POST", api_path("metadata-fields", field_id, "unarchive"), MetadataField + ) + + async def dry_run(self, field_id: str, json_schema: Mapping[str, Any]) -> MetadataFieldImpact: + """Preview what a candidate schema would invalidate, without changing anything. + + Returns how many existing resources hold a value the candidate schema would refuse, + with a few of them as samples. + """ + return await self._api.send( + "POST", + api_path("metadata-fields", field_id, "dry-run"), + MetadataFieldImpact, + body={"jsonSchema": dict(json_schema)}, + idempotent=True, + ) + + async def reorder(self, field_ids: Sequence[str]) -> None: + """Set the display order of metadata fields to the order of ``field_ids``.""" + await self._api.send_empty( + "POST", "/metadata-fields/reorder", body={"ids": list(field_ids)}, idempotent=True + ) diff --git a/src/kaitencloud/_async/resources/platform/__init__.py b/src/kaitencloud/_async/resources/platform/__init__.py new file mode 100644 index 0000000..5b0ec25 --- /dev/null +++ b/src/kaitencloud/_async/resources/platform/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from .connectors import AsyncPlatformConnectors +from .organizations import AsyncPlatformOrganizations +from .tokens import AsyncPlatformTokens +from .users import AsyncPlatformUsers + +__all__ = [ + "AsyncPlatformConnectors", + "AsyncPlatformOrganizations", + "AsyncPlatformTokens", + "AsyncPlatformUsers", +] diff --git a/src/kaitencloud/_async/resources/platform/connectors.py b/src/kaitencloud/_async/resources/platform/connectors.py new file mode 100644 index 0000000..6f79689 --- /dev/null +++ b/src/kaitencloud/_async/resources/platform/connectors.py @@ -0,0 +1,53 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...._utils import compact +from ....types import Connector +from .._base import AsyncResource + +__all__ = ["AsyncPlatformConnectors"] + + +class AsyncPlatformConnectors(AsyncResource): + """The connector registry: the connectors this deployment offers its organizations.""" + + async def register( + self, + *, + name: str, + version: str, + settings_schema: Mapping[str, Any] | None = None, + entitlement_slug: str | None = None, + ) -> Connector: + """Record a connector's manifest, or update the one registered under the same name. + + An upsert on ``name``, so the process that hosts a connector can register it on every + start. Registering says the connector exists in this deployment. Whether an + organization may use it is a separate decision, which each organization makes over the + Core API and ``entitlement_slug`` gates. + + Requires a platform credential holding ``write:organizations``. + + Args: + name: The connector's stable identifier, e.g. ``"kaiten.integration.crm.attio"``. + version: The build of the connector this deployment runs. + settings_schema: The JSON schema organization settings are validated against, and + consoles render a settings form from. A ``writeOnly`` property is a secret: + accepted, never read back. Defaults to an empty schema. + entitlement_slug: The BOOLEAN entitlement a license must grant before an + organization may activate the connector. Omitted, the connector is ungated. + """ + body = { + "name": name, + "version": version, + "settings_schema": dict(settings_schema) if settings_schema is not None else {}, + **compact({"entitlement_slug": entitlement_slug}), + } + return await self._api.send( + "POST", "/platform/connectors", Connector, body=body, idempotent=True + ) diff --git a/src/kaitencloud/_async/resources/platform/organizations.py b/src/kaitencloud/_async/resources/platform/organizations.py new file mode 100644 index 0000000..fac08b2 --- /dev/null +++ b/src/kaitencloud/_async/resources/platform/organizations.py @@ -0,0 +1,68 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from ...._utils import api_path, compact, validate_uuid +from ....types import Organization +from .._base import AsyncResource + +__all__ = ["AsyncPlatformOrganizations"] + + +class AsyncPlatformOrganizations(AsyncResource): + """Organizations: the tenants of this deployment.""" + + async def ensure(self, *, external_id: str, name: str | None = None) -> Organization: + """Create the organization for an identity provider's organization id, or return it. + + The organization's id is derived from ``external_id``, so the same call converges on + the same organization every time -- including one a login already created just in + time. That is what makes it safe to call from a bootstrap on every run. + + Args: + external_id: The identity provider's id for the organization, e.g. + ``"org_2abcDEF"``. + name: Its display name, applied only when this call creates it: an existing + organization is never renamed. Omitted, a new organization is named after its + external id. + """ + return await self._api.send( + "POST", + "/platform/organizations", + Organization, + body=compact({"externalId": external_id, "name": name}), + idempotent=True, + ) + + async def get(self, organization_id: str) -> Organization: + """Return the organization identified by ``organization_id``.""" + return await self._api.get(_organization(organization_id), Organization) + + async def delete(self, organization_id: str) -> None: + """Delete an organization, and with it everything it owns. Irreversible. + + Requires ``delete:organizations``, which ``write:organizations`` does not imply. + """ + await self._api.send_empty("DELETE", _organization(organization_id)) + + async def delete_membership(self, organization_id: str, user_id: str) -> None: + """Remove one user's membership of an organization. + + Requires ``delete:memberships``: a narrower privilege than deleting the organization + or the user. + """ + await self._api.send_empty( + "DELETE", + api_path( + "platform", + "organizations", + validate_uuid(organization_id, "organization_id"), + "memberships", + validate_uuid(user_id, "user_id"), + ), + ) + + +def _organization(organization_id: str) -> str: + return api_path("platform", "organizations", validate_uuid(organization_id, "organization_id")) diff --git a/src/kaitencloud/_async/resources/platform/tokens.py b/src/kaitencloud/_async/resources/platform/tokens.py new file mode 100644 index 0000000..25a2181 --- /dev/null +++ b/src/kaitencloud/_async/resources/platform/tokens.py @@ -0,0 +1,92 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from datetime import timedelta + +from ...._utils import api_path, compact, format_duration, validate_uuid +from ....types import PlainToken, Scope, Token +from .._base import AsyncResource + +__all__ = ["AsyncPlatformTokens"] + + +class AsyncPlatformTokens(AsyncResource): + """Organization tokens (``ksh_...``) minted by this platform credential. + + A platform credential proves who is calling, never where. To act inside an organization + it does not become organization-scoped: it mints an ordinary organization token, bounded + by its own scopes, and the work is done with that. + """ + + async def mint( + self, + organization_id: str, + *, + name: str, + scopes: Sequence[Scope] | None = None, + ttl: timedelta | str | None = None, + ) -> PlainToken: + """Mint an organization token, and return it with its secret. + + The secret, :attr:`~kaitencloud.types.PlainToken.token`, is in this response only. Two + things are easy to get wrong: + + - **Revocation cascades.** Revoking this platform credential revokes every token it + minted. Rotate by minting the new token, adopting it, then revoking the old one -- + never the other way round. + - **The slug is the handle.** :meth:`revoke` takes the token's + :attr:`~kaitencloud.types.PlainToken.slug`, which the server generates. The name you + chose will not do; :meth:`list` maps one to the other. + + Args: + organization_id: The organization to mint the token in. + name: A label, unique among the organization's active tokens. + scopes: The token's scopes, each of which this platform credential must hold; + :data:`~kaitencloud.types.Scope` lists every one an organization token can carry. + ``None`` inherits all of them, which is rarely what a machine consumer needs. + ttl: How long the token lives, as a :class:`~datetime.timedelta` or a Go duration + such as ``"24h"``. Omitted, it lasts until revoked. + + Raises: + PermissionDeniedError: A requested scope is not held by this platform credential. + ConflictError: An active token of the organization already has this name. + """ + body = compact( + { + "name": name, + "scopes": list(scopes) if scopes is not None else None, + "ttl": format_duration(ttl) if ttl is not None else None, + } + ) + return await self._api.send("POST", _tokens(organization_id), PlainToken, body=body) + + async def list(self, organization_id: str) -> builtins.list[Token]: + """Return the active tokens this platform credential minted in an organization. + + It is the way back from the name you chose to the slug :meth:`revoke` takes. Only this + credential's own tokens are listed, and never their secrets. + """ + return await self._api.get_list(_tokens(organization_id), Token) + + async def revoke(self, organization_id: str, token_slug: str) -> None: + """Revoke a token this platform credential minted. Immediate and final.""" + await self._api.send_empty( + "DELETE", + api_path( + "platform", + "organizations", + validate_uuid(organization_id, "organization_id"), + "tokens", + token_slug, + ), + ) + + +def _tokens(organization_id: str) -> str: + return api_path( + "platform", "organizations", validate_uuid(organization_id, "organization_id"), "tokens" + ) diff --git a/src/kaitencloud/_async/resources/platform/users.py b/src/kaitencloud/_async/resources/platform/users.py new file mode 100644 index 0000000..e001756 --- /dev/null +++ b/src/kaitencloud/_async/resources/platform/users.py @@ -0,0 +1,26 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from ...._utils import api_path, validate_uuid +from .._base import AsyncResource + +__all__ = ["AsyncPlatformUsers"] + + +class AsyncPlatformUsers(AsyncResource): + """Users, across every organization. + + Users are provisioned when they first sign in, so deletion is the only operation. + """ + + async def delete(self, user_id: str) -> None: + """Delete a user, in every organization. + + The user is soft-deleted -- rows it created keep their attribution -- and signing in + again does not bring it back. Requires ``delete:users``. + """ + await self._api.send_empty( + "DELETE", api_path("platform", "users", validate_uuid(user_id, "user_id")) + ) diff --git a/src/kaitencloud/_async/resources/releases.py b/src/kaitencloud/_async/resources/releases.py new file mode 100644 index 0000000..329571d --- /dev/null +++ b/src/kaitencloud/_async/resources/releases.py @@ -0,0 +1,59 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Sequence + +from ..._utils import api_path, compact +from ...types import Release +from ._base import AsyncResource + +__all__ = ["AsyncReleases"] + + +class AsyncReleases(AsyncResource): + """Releases: immutable, versioned sets of components. + + A release cannot be edited. Changing one means deleting it and creating another, which + loses the deployment history attached to the first. + """ + + async def list(self) -> builtins.list[Release]: + """Return every release, walking all pages.""" + return await self._api.list_all("/releases", Release) + + async def get(self, release_slug: str) -> Release: + """Return the release identified by ``release_slug``, with its components.""" + return await self._api.get(api_path("releases", release_slug), Release) + + async def create( + self, + *, + version: str, + description: str | None = None, + component_ids: Sequence[str] | None = None, + slug: str | None = None, + ) -> Release: + """Create a release. + + Args: + version: Its version, e.g. ``"v1.4.0"``, unique in the organization. + description: A description or changelog, up to 500 characters. + component_ids: The ids of the components it is made of, fixed from now on. + slug: Its URL-friendly identifier, generated when omitted. + """ + body = compact( + { + "version": version, + "description": description, + "componentIds": list(component_ids) if component_ids is not None else None, + "slug": slug, + } + ) + return await self._api.send("POST", "/releases", Release, body=body) + + async def delete(self, release_slug: str) -> None: + """Delete the release identified by ``release_slug``.""" + await self._api.send_empty("DELETE", api_path("releases", release_slug)) diff --git a/src/kaitencloud/_async/resources/service_accounts.py b/src/kaitencloud/_async/resources/service_accounts.py new file mode 100644 index 0000000..2ef79de --- /dev/null +++ b/src/kaitencloud/_async/resources/service_accounts.py @@ -0,0 +1,93 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from datetime import datetime + +from ..._utils import api_path, compact, optional_datetime +from ...types import PlainToken, Scope, ServiceAccount, Token +from ._base import AsyncResource + +__all__ = ["AsyncServiceAccounts"] + + +class AsyncServiceAccounts(AsyncResource): + """Service accounts: machine identities in your organization, and their API tokens.""" + + async def list(self) -> builtins.list[ServiceAccount]: + """Return every service account, walking all pages.""" + return await self._api.list_all("/service-accounts", ServiceAccount) + + async def get(self, service_account_slug: str) -> ServiceAccount: + """Return the service account identified by ``service_account_slug``.""" + return await self._api.get( + api_path("service-accounts", service_account_slug), ServiceAccount + ) + + async def create(self, *, name: str, slug: str | None = None) -> ServiceAccount: + """Create a service account. + + Args: + name: The account's name, e.g. ``"billing-sync"``. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = compact({"name": name, "slug": slug}) + return await self._api.send("POST", "/service-accounts", ServiceAccount, body=body) + + async def update(self, service_account_slug: str, *, name: str) -> None: + """Rename a service account. Its slug cannot be changed.""" + await self._api.send_empty( + "PUT", api_path("service-accounts", service_account_slug), body={"name": name} + ) + + async def list_tokens(self, service_account_slug: str) -> builtins.list[Token]: + """Return the service account's tokens, walking all pages. Never their secrets.""" + return await self._api.list_all( + api_path("service-accounts", service_account_slug, "tokens"), Token + ) + + async def create_token( + self, + service_account_slug: str, + *, + name: str, + scopes: Sequence[Scope] | None = None, + slug: str | None = None, + expires_at: datetime | str | None = None, + ) -> PlainToken: + """Create a token for the service account, and return it with its secret. + + The secret, :attr:`~kaitencloud.types.PlainToken.token`, is in this response and + nowhere else: the API keeps only a hash of it. Store it now -- a lost secret can be + replaced, never recovered. + + Args: + service_account_slug: The service account the token authenticates as. + name: A label, unique among the account's active tokens. + scopes: What the token may do, e.g. ``["read:customers", "write:instances"]``; + :data:`~kaitencloud.types.Scope` lists every one the API knows. ``None`` + inherits the service account's scopes. ``write:X`` implies ``read:X``. + slug: Its URL-friendly identifier, generated when omitted. It is what + :meth:`delete_token` takes. + expires_at: When the token stops working. Omitted, it lasts until revoked. + """ + body = { + "name": name, + "scopes": list(scopes) if scopes is not None else None, + **compact({"slug": slug, "expiresAt": optional_datetime(expires_at)}), + } + return await self._api.send( + "POST", + api_path("service-accounts", service_account_slug, "tokens"), + PlainToken, + body=body, + ) + + async def delete_token(self, service_account_slug: str, token_slug: str) -> None: + """Revoke the token identified by ``token_slug``. The revocation is immediate.""" + await self._api.send_empty( + "DELETE", api_path("service-accounts", service_account_slug, "tokens", token_slug) + ) diff --git a/src/kaitencloud/_constants.py b/src/kaitencloud/_constants.py new file mode 100644 index 0000000..7acd6a3 --- /dev/null +++ b/src/kaitencloud/_constants.py @@ -0,0 +1,32 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Defaults shared by both clients.""" + +from __future__ import annotations + +from typing import Final + +import httpx + +DEFAULT_TIMEOUT: Final = httpx.Timeout(30.0, connect=10.0) +"""The timeout of the HTTP client a Kaiten client builds for itself.""" + +DEFAULT_MAX_RETRIES: Final = 2 +"""How many times a failed request that is safe to repeat is retried by default.""" + +MAX_PAGE_SIZE: Final = 200 +"""The largest page Kaiten's list endpoints accept. + +A hard ceiling, not a hint: the endpoints declare ``maximum: 200`` and answer 201 with a 422. +Walking at this size is the fewest round trips a complete list can take. +""" + +INITIAL_RETRY_DELAY: Final = 0.25 +MAX_RETRY_DELAY: Final = 8.0 +MAX_RETRY_AFTER: Final = 60.0 + +ENV_BASE_URL: Final = "KAITEN_BASE_URL" +ENV_TOKEN: Final = "KAITEN_AUTH_TOKEN" +ENV_PLATFORM_BASE_URL: Final = "KAITEN_PLATFORM_BASE_URL" +ENV_PLATFORM_TOKEN: Final = "KAITEN_PLATFORM_TOKEN" diff --git a/src/kaitencloud/_exceptions.py b/src/kaitencloud/_exceptions.py new file mode 100644 index 0000000..23c680e --- /dev/null +++ b/src/kaitencloud/_exceptions.py @@ -0,0 +1,254 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Every error the SDK raises, rooted at :class:`KaitenError`.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx +from pydantic import ValidationError + +from .types import ErrorDetail, Problem + +__all__ = [ + "APIConnectionError", + "APIResponseValidationError", + "APIStatusError", + "APITimeoutError", + "AuthenticationError", + "BadRequestError", + "ConflictError", + "CredentialError", + "InternalServerError", + "KaitenError", + "NotFoundError", + "PaginationError", + "PermissionDeniedError", + "RateLimitError", + "ServiceUnavailableError", + "ThresholdExceededError", + "UnprocessableEntityError", + "WebhookPayloadError", + "WebhookVerificationError", +] + + +class KaitenError(Exception): + """Base class of every error the SDK raises.""" + + +class CredentialError(KaitenError, ValueError): + """A credential is missing, or belongs to a class the target API refuses. + + Raised before anything is sent: a platform credential (``ksm_...``) handed to the Core + client, an organization token handed to the platform client, or a webhook signing secret + (``whsec_...``) handed to either. + """ + + +class APIConnectionError(KaitenError): + """The request did not reach the API, or its response never came back.""" + + def __init__( + self, message: str = "Could not reach the Kaiten API.", *, request: httpx.Request + ) -> None: + super().__init__(message) + self.request = request + + +class APITimeoutError(APIConnectionError): + """The request timed out.""" + + def __init__(self, *, request: httpx.Request) -> None: + super().__init__("The request to the Kaiten API timed out.", request=request) + + +class APIResponseValidationError(KaitenError): + """A successful response does not have the shape the contract promises.""" + + def __init__(self, message: str, *, response: httpx.Response) -> None: + super().__init__(message) + self.response = response + + +class PaginationError(KaitenError): + """A list endpoint reported more rows without a usable cursor to reach them. + + Raised instead of returning the rows fetched so far: a partial list handed back as a + complete one is the one failure a caller has no way to notice. + """ + + +class APIStatusError(KaitenError): + """The API answered with a non-2xx status. + + Branch on :attr:`code` where the API provides one, rather than on the status or the + message: a 409 for a reached limit and a 409 for a taken slug share a status, not a meaning. + """ + + def __init__(self, message: str, *, response: httpx.Response, problem: Problem | None) -> None: + super().__init__(message) + self.response = response + self.status_code = response.status_code + self.problem = problem + + @property + def request(self) -> httpx.Request: + """The request that failed.""" + return self.response.request + + @property + def code(self) -> str | None: + """The stable, machine-readable error code, e.g. ``"License.NotFound"``, when sent.""" + return self.problem.code if self.problem else None + + @property + def title(self) -> str | None: + """The short summary of the problem.""" + return self.problem.title if self.problem else None + + @property + def detail(self) -> str | None: + """The explanation of this occurrence of the problem.""" + return self.problem.detail if self.problem else None + + @property + def error_id(self) -> str | None: + """The id of the server-side log entry holding the cause the API withheld. + + Quote it when reporting a bug: it is the only handle on the real error. + """ + return self.problem.error_id if self.problem else None + + @property + def errors(self) -> list[ErrorDetail]: + """The individual validation errors, when the API listed them.""" + return list(self.problem.errors or []) if self.problem else [] + + @property + def body(self) -> str: + """The raw response body.""" + return self.response.text + + +class BadRequestError(APIStatusError): + """The API answered 400: the request is malformed.""" + + +class AuthenticationError(APIStatusError): + """The API answered 401: the credential is missing, invalid, expired or revoked.""" + + +class PermissionDeniedError(APIStatusError): + """The API answered 403: the credential lacks a scope, or its class is refused here.""" + + +class NotFoundError(APIStatusError): + """The API answered 404.""" + + +class ConflictError(APIStatusError): + """The API answered 409: a limit was reached, or the resource conflicts with another.""" + + +class ThresholdExceededError(ConflictError): + """A usage report would take usage past the maximum the license allows. + + That maximum is the grant's value plus its overage allowance. The report was refused and + nothing was recorded. + """ + + +class UnprocessableEntityError(APIStatusError): + """The API answered 422: the request is well-formed but its content is refused.""" + + +class RateLimitError(APIStatusError): + """The API answered 429.""" + + +class InternalServerError(APIStatusError): + """The API answered with a 5xx status.""" + + +class ServiceUnavailableError(InternalServerError): + """The API answered 503. + + Kaiten answers 503 when it cannot verify a limit a creation is subject to: it refuses the + creation rather than let through one the plan might not allow. + """ + + +class WebhookVerificationError(KaitenError): + """A webhook delivery failed verification: answer 400 and do not act on it.""" + + +class WebhookPayloadError(KaitenError): + """A verified webhook payload does not match the event contract it names.""" + + +_STATUS_ERRORS: Mapping[int, type[APIStatusError]] = { + 400: BadRequestError, + 401: AuthenticationError, + 403: PermissionDeniedError, + 404: NotFoundError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: RateLimitError, + 503: ServiceUnavailableError, +} + + +def make_status_error( + response: httpx.Response, + *, + overrides: Mapping[int, type[APIStatusError]] | None = None, +) -> APIStatusError: + """Build the error for a non-2xx ``response``.""" + status = response.status_code + error_class = (overrides or {}).get(status) or _STATUS_ERRORS.get(status) + if error_class is None: + error_class = InternalServerError if status >= 500 else APIStatusError + problem = parse_problem(response) + return error_class(_message(response, problem), response=response, problem=problem) + + +def parse_problem(response: httpx.Response) -> Problem | None: + """The RFC 9457 problem in ``response``, or ``None`` when the body is not one. + + Only a problem that says something is kept: a proxy's HTML error page or an empty body is + better reported raw than as a problem whose every field is empty. + """ + try: + data: Any = response.json() + except ValueError: + return None + if not isinstance(data, dict) or not any(data.get(key) for key in ("title", "detail", "code")): + return None + try: + return Problem.model_validate(data) + except ValidationError: + return None + + +def _message(response: httpx.Response, problem: Problem | None) -> str: + reason = response.reason_phrase or httpx.codes.get_reason_phrase(response.status_code) + prefix = f"Kaiten API error ({response.status_code} {reason})".replace(" )", ")") + suffix = "" + if problem is not None: + if problem.code: + prefix += f" [{problem.code}]" + if problem.error_id: + suffix = f" (errorId: {problem.error_id})" + text = ": ".join( + part.strip() for part in (problem.title, problem.detail) if part and part.strip() + ) + if text: + return f"{prefix}: {text}{suffix}" + body = response.text.strip() + if body: + return f"{prefix}: {body[:500]}{suffix}" + return prefix + suffix diff --git a/src/kaitencloud/_models.py b/src/kaitencloud/_models.py new file mode 100644 index 0000000..1f1a869 --- /dev/null +++ b/src/kaitencloud/_models.py @@ -0,0 +1,39 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The base class of every model the SDK returns.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +__all__ = ["KaitenModel"] + + +class KaitenModel(BaseModel): + """Base class of every model the SDK returns. + + Models are lenient where the API grows. A field added to the API after this release is + kept rather than rejected -- read it from :attr:`model_extra` -- and an enum value the SDK + does not know yet parses as a plain string. + + Attributes are snake_case. :meth:`to_dict` and :meth:`to_json` give back the exact wire + shape, camelCase keys included, which is what to send when echoing a resource back. + """ + + model_config = ConfigDict( + extra="allow", + validate_by_alias=True, + validate_by_name=True, + serialize_by_alias=True, + ) + + def to_dict(self) -> dict[str, Any]: + """Return the model as the API spells it: JSON-compatible values, wire key names.""" + return self.model_dump(mode="json", by_alias=True, exclude_unset=True) + + def to_json(self, *, indent: int | None = None) -> str: + """Return the model as a JSON document, with wire key names.""" + return self.model_dump_json(by_alias=True, exclude_unset=True, indent=indent) diff --git a/src/kaitencloud/_sync/__init__.py b/src/kaitencloud/_sync/__init__.py new file mode 100644 index 0000000..535a9ce --- /dev/null +++ b/src/kaitencloud/_sync/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/__init__.py. Do not edit by hand: +# change the async source, then run `task generate`. +"""The Kaiten clients and their resources.""" diff --git a/src/kaitencloud/_sync/_http.py b/src/kaitencloud/_sync/_http.py new file mode 100644 index 0000000..7c44ffc --- /dev/null +++ b/src/kaitencloud/_sync/_http.py @@ -0,0 +1,329 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/_http.py. Do not edit by hand: +# change the async source, then run `task generate`. +"""The transport both clients share: authentication, retries, errors and pagination.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator, Mapping +from typing import Any, TypeVar + +import httpx +from pydantic import BaseModel, ValidationError + +from .._constants import DEFAULT_TIMEOUT, MAX_PAGE_SIZE +from .._exceptions import ( + APIConnectionError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + PaginationError, + make_status_error, +) +from .._types import NOT_GIVEN, Credential, NotGiven, TimeoutTypes +from .._utils import USER_AGENT, Surface, check_token, resolve_token, retry_delay, sleep + +__all__ = ["APIClient"] + +ModelT = TypeVar("ModelT", bound=BaseModel) + +log = logging.getLogger("kaitencloud") + +# Methods a retry cannot turn into a second effect. POST and PATCH are not among them: a usage +# report retried after a lost response is usage counted twice, and usage is what is billed. +IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) + +# Failures that happen before a request is sent, and so are safe to retry whatever its method. +NEVER_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) + + +class APIClient: + """Sends a client's requests: authenticates, retries what is safe, maps errors, walks pages.""" + + def __init__( + self, + *, + base_url: str, + credential: Credential, + surface: Surface, + max_retries: int, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + http_client: httpx.Client | None = None, + ) -> None: + if max_retries < 0: + raise ValueError(f"max_retries must be zero or more, got {max_retries}") + self.base_url = base_url + self.credential = credential + self.surface = surface + self.max_retries = max_retries + self.timeout = timeout + self.default_headers = dict(default_headers or {}) + self.owns_http_client = http_client is None + self.http_client = http_client or httpx.Client( + timeout=DEFAULT_TIMEOUT if isinstance(timeout, NotGiven) else timeout + ) + + def copy( + self, + *, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | NotGiven = NOT_GIVEN, + ) -> APIClient: + """A transport with some options changed, sharing this one's connection pool.""" + return APIClient( + base_url=self.base_url, + credential=self.credential, + surface=self.surface, + max_retries=self.max_retries if isinstance(max_retries, NotGiven) else max_retries, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + default_headers=( + self.default_headers if isinstance(default_headers, NotGiven) else default_headers + ), + http_client=self.http_client, + ) + + def close(self) -> None: + if self.owns_http_client: + self.http_client.close() + + def request( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + body: Any = None, + idempotent: bool | None = None, + errors: Mapping[int, type[APIStatusError]] | None = None, + ) -> httpx.Response: + """Send one operation and return its successful response. + + A request is retried, up to ``max_retries`` times, when it failed in a way worth + retrying (a connection error, a 408, 429 or 5xx) *and* repeating it is safe: its method + is idempotent, ``idempotent=True`` says so, or it never left the machine. A 401 or 403 is + never retried -- the SDK cannot mint a better credential, and hammering a rejected one + only burns the rate budget. + """ + url = self.base_url + path.lstrip("/") + retryable = method in IDEMPOTENT_METHODS if idempotent is None else idempotent + attempt = 0 + + while True: + request = self._build( + method, url, params=_query(params), body=body, headers=self._headers() + ) + try: + response = self.http_client.send(request) + except httpx.TransportError as error: + if attempt < self.max_retries and (retryable or isinstance(error, NEVER_SENT)): + self._back_off(request, attempt, repr(error)) + attempt += 1 + continue + if isinstance(error, httpx.TimeoutException): + raise APITimeoutError(request=request) from error + raise APIConnectionError(request=request) from error + + if response.is_success: + return response + + status = response.status_code + if retryable and attempt < self.max_retries and (status in (408, 429) or status >= 500): + self._back_off( + request, attempt, f"HTTP {status}", response.headers.get("retry-after") + ) + attempt += 1 + continue + raise make_status_error(response, overrides=errors) + + def get( + self, path: str, model: type[ModelT], *, params: Mapping[str, Any] | None = None + ) -> ModelT: + return self.send("GET", path, model, params=params) + + def get_list( + self, path: str, model: type[ModelT], *, params: Mapping[str, Any] | None = None + ) -> list[ModelT]: + """GET an unpaginated array, which the contracts type as nullable: null is empty.""" + response = self.request("GET", path, params=params) + data = self.decode(response) + if data is None: + return [] + if not isinstance(data, list): + raise APIResponseValidationError( + f"GET {path}: expected a JSON array, got {type(data).__name__}", response=response + ) + return [self.validate(model, item, response) for item in data] + + def send( + self, + method: str, + path: str, + model: type[ModelT], + *, + params: Mapping[str, Any] | None = None, + body: Any = None, + idempotent: bool | None = None, + errors: Mapping[int, type[APIStatusError]] | None = None, + ) -> ModelT: + response = self.request( + method, path, params=params, body=body, idempotent=idempotent, errors=errors + ) + return self.validate(model, self.decode(response), response) + + def send_empty( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + body: Any = None, + idempotent: bool | None = None, + ) -> None: + self.request(method, path, params=params, body=body, idempotent=idempotent) + + def paginate( + self, + path: str, + model: type[ModelT], + *, + params: Mapping[str, Any] | None = None, + limit: int | None = None, + ) -> Iterator[ModelT]: + """Yield every row of a cursor-paginated collection, page after page. + + ``limit`` caps how many rows are yielded, and no page asks for more rows than are still + wanted. Without it, the walk ends only when the API says there is nothing more. + """ + filters = dict(params or {}) + cursor: str | None = None + yielded = 0 + items: list[Any] + has_more: object + next_cursor: object + while True: + size = MAX_PAGE_SIZE if limit is None else min(MAX_PAGE_SIZE, limit - yielded) + response = self.request( + "GET", path, params={**filters, "limit": size, "cursor": cursor} + ) + page = self.decode(response) + + if isinstance(page, list): + # A deployment older than cursor pagination answers the whole collection. + items, has_more, next_cursor = page, False, None + elif isinstance(page, dict) and isinstance(page.get("items"), list): + items, has_more, next_cursor = ( + page["items"], + page.get("hasMore"), + page.get("nextCursor"), + ) + else: + raise APIResponseValidationError( + f"GET {path}: expected a page of items", response=response + ) + + for item in items: + yield self.validate(model, item, response) + yielded += 1 + if limit is not None and yielded >= limit: + return + if not has_more: + return + if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor: + raise PaginationError( + f"GET {path}: the API reported more rows but returned no usable next cursor" + ) + cursor = next_cursor + + def list_all( + self, + path: str, + model: type[ModelT], + *, + params: Mapping[str, Any] | None = None, + limit: int | None = None, + ) -> list[ModelT]: + rows: list[ModelT] = [] + for row in self.paginate(path, model, params=params, limit=limit): + rows.append(row) + return rows + + @staticmethod + def decode(response: httpx.Response) -> Any: + if not response.content: + return None + try: + return response.json() + except ValueError as error: + raise APIResponseValidationError( + f"{response.request.method} {response.request.url.path}: the response is not JSON", + response=response, + ) from error + + @staticmethod + def validate(model: type[ModelT], data: Any, response: httpx.Response) -> ModelT: + try: + return model.model_validate(data) + except ValidationError as error: + raise APIResponseValidationError( + f"{response.request.method} {response.request.url.path}: the response does not " + f"match {model.__name__}: {error}", + response=response, + ) from error + + def _build( + self, + method: str, + url: str, + *, + params: dict[str, Any] | None, + body: Any, + headers: dict[str, str], + ) -> httpx.Request: + if isinstance(self.timeout, NotGiven): + # The HTTP client's own timeout applies: the default one, or the caller's. + return self.http_client.build_request( + method, url, params=params, json=body, headers=headers + ) + return self.http_client.build_request( + method, url, params=params, json=body, headers=headers, timeout=self.timeout + ) + + def _headers(self) -> dict[str, str]: + token = check_token(resolve_token(self.credential), self.surface) + return { + "Accept": "application/json", + "User-Agent": USER_AGENT, + **self.default_headers, + "Authorization": f"Bearer {token}", + } + + def _back_off( + self, request: httpx.Request, attempt: int, reason: str, retry_after: str | None = None + ) -> None: + delay = retry_delay(attempt, retry_after) + log.info( + "Retrying %s %s in %.2fs (retry %d of %d) after %s", + request.method, + request.url.path, + delay, + attempt + 1, + self.max_retries, + reason, + ) + sleep(delay) + + +def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not params: + return None + query: dict[str, Any] = {} + for key, value in params.items(): + if value is None: + continue + query[key] = ("true" if value else "false") if isinstance(value, bool) else value + return query diff --git a/src/kaitencloud/_sync/client.py b/src/kaitencloud/_sync/client.py new file mode 100644 index 0000000..994af67 --- /dev/null +++ b/src/kaitencloud/_sync/client.py @@ -0,0 +1,312 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/client.py. Do not edit by hand: +# change the async source, then run `task generate`. +"""The Kaiten API clients: one per API surface.""" + +from __future__ import annotations + +import copy +import os +from collections.abc import Mapping +from types import TracebackType + +import httpx +from typing_extensions import Self + +from .._constants import ( + DEFAULT_MAX_RETRIES, + ENV_BASE_URL, + ENV_PLATFORM_BASE_URL, + ENV_PLATFORM_TOKEN, + ENV_TOKEN, +) +from .._exceptions import CredentialError +from .._types import NOT_GIVEN, Credential, NotGiven, TimeoutTypes +from .._utils import normalize_base_url +from ..types import PlatformCredential +from ._http import APIClient +from .resources import ( + Components, + Connectors, + Customers, + DeploymentZones, + EntitlementGroups, + Entitlements, + FeatureFlags, + Instances, + Integrations, + LicenseFamilies, + Licenses, + MetadataFields, + Releases, + ServiceAccounts, +) +from .resources.platform import ( + PlatformConnectors, + PlatformOrganizations, + PlatformTokens, + PlatformUsers, +) + +__all__ = ["KaitenClient", "KaitenPlatformClient"] + + +class _BaseClient: + _api: APIClient + + @property + def base_url(self) -> str: + """The API prefix every request goes to, always ending in ``/api/``.""" + return self._api.base_url + + @property + def max_retries(self) -> int: + """How many times a failed request that is safe to repeat is retried.""" + return self._api.max_retries + + def with_options( + self, + *, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | NotGiven = NOT_GIVEN, + ) -> Self: + """Return a copy of this client with some options changed. + + The copy shares this client's connection pool, so it is cheap to make per call:: + + client.with_options(timeout=5, max_retries=0).customers.get("acme") + + Closing the copy leaves the pool open; closing the original closes it for both. + """ + clone = copy.copy(self) + clone._api = self._api.copy( + timeout=timeout, max_retries=max_retries, default_headers=default_headers + ) + clone._bind() + return clone + + def close(self) -> None: + """Close the connection pool, unless it was passed in as ``http_client``.""" + self._api.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def _bind(self) -> None: + raise NotImplementedError + + def __repr__(self) -> str: + return f"{type(self).__name__}(base_url={self.base_url!r})" + + +class KaitenClient(_BaseClient): + """Client for the Kaiten Core API. + + The Core API is what an organization's own credential talks to: an organization token + (``ksh_...``) or a JWT from your identity provider. It covers customers, instances, + licenses, entitlements and usage, feature flags, releases and connectors:: + + with KaitenClient(token="ksh_...") as client: + for customer in client.customers.list(): + print(customer.slug) + + Args: + token: The credential, or a callable returning it -- called before every request, so a + rotated token is picked up. Defaults to the ``KAITEN_AUTH_TOKEN`` environment + variable. A platform credential (``ksm_...``) is refused: use + :class:`KaitenPlatformClient`. + base_url: Where your Kaiten deployment's API lives, e.g. ``http://localhost:6000``. + ``/api`` is appended when missing. Defaults to ``KAITEN_BASE_URL``; there is no + default beyond it, since every deployment has an address of its own. + timeout: Seconds, or an :class:`httpx.Timeout`. Defaults to 30 seconds. + max_retries: How many times a failed request that is safe to repeat is retried. + default_headers: Headers sent with every request. + http_client: An :class:`httpx.Client` to send requests with, for proxies, custom + TLS or transports. The Kaiten client never closes a client it did not create. + """ + + components: Components + """Components: the versioned building blocks releases are made of.""" + + connectors: Connectors + """Connectors: activation and settings of the integrations this deployment offers.""" + + customers: Customers + """Customers: the end clients of your product.""" + + deployment_zones: DeploymentZones + """Deployment zones: the environments instances run in.""" + + entitlement_groups: EntitlementGroups + """Entitlement groups: named sets of entitlements.""" + + entitlements: Entitlements + """Entitlements: the catalog of what licenses can grant.""" + + feature_flags: FeatureFlags + """Feature flags: their definitions. Evaluate them through OpenFeature.""" + + instances: Instances + """Instances: deployments of your product, their usage and audit trail.""" + + integrations: Integrations + """Customers and instances addressed by their identifier in a third-party system.""" + + license_families: LicenseFamilies + """License families: the products, each resolved to the version it currently serves.""" + + licenses: Licenses + """Licenses: the versions of a license family, and the entitlement values they grant.""" + + metadata_fields: MetadataFields + """Metadata fields: the typed fields of instances and deployment zones.""" + + releases: Releases + """Releases: immutable, versioned sets of components.""" + + service_accounts: ServiceAccounts + """Service accounts and their tokens.""" + + def __init__( + self, + *, + token: Credential | None = None, + base_url: str | None = None, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + http_client: httpx.Client | None = None, + ) -> None: + credential = token if token is not None else os.environ.get(ENV_TOKEN) + if credential is None or (isinstance(credential, str) and not credential.strip()): + raise CredentialError(f"No credential: pass token=... or set {ENV_TOKEN}.") + url = base_url or os.environ.get(ENV_BASE_URL) + if not url or not url.strip(): + raise ValueError( + "No Kaiten API address: pass base_url=... (your deployment's, e.g. " + f"http://localhost:6000) or set {ENV_BASE_URL}." + ) + self._api = APIClient( + base_url=normalize_base_url(url), + credential=credential, + surface="core", + max_retries=max_retries, + timeout=timeout, + default_headers=default_headers, + http_client=http_client, + ) + self._bind() + + def _bind(self) -> None: + api = self._api + self.components = Components(api) + self.connectors = Connectors(api) + self.customers = Customers(api) + self.deployment_zones = DeploymentZones(api) + self.entitlement_groups = EntitlementGroups(api) + self.entitlements = Entitlements(api) + self.feature_flags = FeatureFlags(api) + self.instances = Instances(api) + self.integrations = Integrations(api) + self.license_families = LicenseFamilies(api) + self.licenses = Licenses(api) + self.metadata_fields = MetadataFields(api) + self.releases = Releases(api) + self.service_accounts = ServiceAccounts(api) + + +class KaitenPlatformClient(_BaseClient): + """Client for the Kaiten Platform API. + + The Platform API administers a deployment across organizations: it ensures + organizations exist, mints the organization tokens that Core API callers use, and holds + the connector registry. It listens on its own port, on no public route, and accepts only + a platform credential (``ksm_...``):: + + with KaitenPlatformClient( + base_url="http://kaiten-api.kaiten.svc:6001", token="ksm_..." + ) as platform: + organization = platform.organizations.ensure(external_id="org_2abcDEF") + minted = platform.tokens.mint(organization.id, name="billing-sync") + + Args: + token: The platform credential, or a callable returning it. Defaults to the + ``KAITEN_PLATFORM_TOKEN`` environment variable. + base_url: The Platform API listener -- not the Core API's address, which answers 404 + to every operation here. Defaults to ``KAITEN_PLATFORM_BASE_URL``; required. + timeout: Seconds, or an :class:`httpx.Timeout`. Defaults to 30 seconds. + max_retries: How many times a failed request that is safe to repeat is retried. + default_headers: Headers sent with every request. + http_client: An :class:`httpx.Client` to send requests with. + """ + + connectors: PlatformConnectors + """The deployment-wide connector registry.""" + + organizations: PlatformOrganizations + """Organizations: ensure, read, delete, and remove memberships.""" + + tokens: PlatformTokens + """Organization tokens this platform credential mints, lists and revokes.""" + + users: PlatformUsers + """Users, across every organization.""" + + def __init__( + self, + *, + token: Credential | None = None, + base_url: str | None = None, + timeout: TimeoutTypes | NotGiven = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + http_client: httpx.Client | None = None, + ) -> None: + credential = token if token is not None else os.environ.get(ENV_PLATFORM_TOKEN) + if credential is None or (isinstance(credential, str) and not credential.strip()): + raise CredentialError( + f"No platform credential: pass token=... or set {ENV_PLATFORM_TOKEN}." + ) + url = base_url or os.environ.get(ENV_PLATFORM_BASE_URL) + if not url: + raise ValueError( + "The Platform API has no default address: pass base_url=... (its own listener, " + f"e.g. http://kaiten-api:6001) or set {ENV_PLATFORM_BASE_URL}." + ) + self._api = APIClient( + base_url=normalize_base_url(url), + credential=credential, + surface="platform", + max_retries=max_retries, + timeout=timeout, + default_headers=default_headers, + http_client=http_client, + ) + self._bind() + + def me(self) -> PlatformCredential: + """Describe the platform credential this client authenticates with. + + Its name, scopes, expiry and identity -- never its value, which exists only at creation. + Cheap enough to probe a stored credential with: it is the one way to tell a revoked or + expired credential from a working one without attempting a write. + """ + return self._api.get("/platform/me", PlatformCredential) + + def _bind(self) -> None: + api = self._api + self.connectors = PlatformConnectors(api) + self.organizations = PlatformOrganizations(api) + self.tokens = PlatformTokens(api) + self.users = PlatformUsers(api) diff --git a/src/kaitencloud/_sync/resources/__init__.py b/src/kaitencloud/_sync/resources/__init__.py new file mode 100644 index 0000000..ddad12b --- /dev/null +++ b/src/kaitencloud/_sync/resources/__init__.py @@ -0,0 +1,36 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/__init__.py. Do not edit by hand: +# change the async source, then run `task generate`. +from .components import Components +from .connectors import Connectors +from .customers import Customers +from .deployment_zones import DeploymentZones +from .entitlement_groups import EntitlementGroups +from .entitlements import Entitlements +from .feature_flags import FeatureFlags +from .instances import Instances +from .integrations import Integrations +from .license_families import LicenseFamilies +from .licenses import Licenses +from .metadata_fields import MetadataFields +from .releases import Releases +from .service_accounts import ServiceAccounts + +__all__ = [ + "Components", + "Connectors", + "Customers", + "DeploymentZones", + "EntitlementGroups", + "Entitlements", + "FeatureFlags", + "Instances", + "Integrations", + "LicenseFamilies", + "Licenses", + "MetadataFields", + "Releases", + "ServiceAccounts", +] diff --git a/src/kaitencloud/_sync/resources/_base.py b/src/kaitencloud/_sync/resources/_base.py new file mode 100644 index 0000000..43d5f84 --- /dev/null +++ b/src/kaitencloud/_sync/resources/_base.py @@ -0,0 +1,22 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/_base.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +from .._http import APIClient + +__all__ = ["Resource"] + + +class Resource: + """Base class of a resource namespace, such as ``client.customers``.""" + + __slots__ = ("_api",) + + def __init__(self, api: APIClient) -> None: + self._api = api + + def __repr__(self) -> str: + return f"<{type(self).__name__} base_url={self._api.base_url!r}>" diff --git a/src/kaitencloud/_sync/resources/components.py b/src/kaitencloud/_sync/resources/components.py new file mode 100644 index 0000000..76b9710 --- /dev/null +++ b/src/kaitencloud/_sync/resources/components.py @@ -0,0 +1,76 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/components.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins + +from ..._utils import api_path, compact +from ...types import Component +from ._base import Resource + +__all__ = ["Components"] + + +class Components(Resource): + """Components: the versioned building blocks of your product that releases are made of.""" + + def list(self) -> builtins.list[Component]: + """Return every component, walking all pages.""" + return self._api.list_all("/components", Component) + + def get(self, component_slug: str) -> Component: + """Return the component identified by ``component_slug``.""" + return self._api.get(api_path("components", component_slug), Component) + + def create( + self, + *, + name: str, + version: str, + description: str | None = None, + previous_component_id: str | None = None, + slug: str | None = None, + ) -> Component: + """Create a component. + + Args: + name: The component's name, e.g. ``"api-gateway"``. + version: Its version, e.g. ``"v1.2.3"``. + description: A description, up to 500 characters. + previous_component_id: The id of the component this one succeeds. The link is set + here, once: an update cannot change it. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = compact( + { + "name": name, + "version": version, + "description": description, + "previousComponentId": previous_component_id, + "slug": slug, + } + ) + return self._api.send("POST", "/components", Component, body=body) + + def update( + self, + component_slug: str, + *, + name: str, + version: str, + description: str | None = None, + ) -> Component: + """Replace a component's name, version and description, and return it. + + A full replacement: a description left out is cleared. Neither the slug nor the + previous component can be changed. + """ + body = compact({"name": name, "version": version, "description": description}) + return self._api.send("PUT", api_path("components", component_slug), Component, body=body) + + def delete(self, component_slug: str) -> None: + """Delete the component identified by ``component_slug``.""" + self._api.send_empty("DELETE", api_path("components", component_slug)) diff --git a/src/kaitencloud/_sync/resources/connectors.py b/src/kaitencloud/_sync/resources/connectors.py new file mode 100644 index 0000000..cb96460 --- /dev/null +++ b/src/kaitencloud/_sync/resources/connectors.py @@ -0,0 +1,92 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/connectors.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from ..._utils import api_path +from ...types import ( + Connector, + ConnectorActivation, + ConnectorSettings, + ConnectorSettingsSchema, + ConnectorState, +) +from ._base import Resource + +__all__ = ["Connectors"] + + +class Connectors(Resource): + """Connectors: the integrations this deployment offers, and this organization's use of them. + + Registering a connector is deployment-wide and belongs to the Platform API + (``KaitenPlatformClient.connectors``). Activating and configuring one is each + organization's own decision, made here. + """ + + def list(self) -> builtins.list[Connector]: + """Return every connector registered with this deployment.""" + return self._api.get_list("/connectors", Connector) + + def get(self, connector_name: str) -> Connector: + """Return the connector registered as ``connector_name``.""" + return self._api.get(api_path("connectors", connector_name), Connector) + + def get_state(self, connector_name: str) -> ConnectorState: + """Return whether the connector is available, licensed, and activated for this organization. + + Three separate answers, where a single 404 could never tell "not registered here" from + "not in your license" from "not turned on". + """ + return self._api.get(api_path("connectors", connector_name, "state"), ConnectorState) + + def activate(self, connector_name: str) -> ConnectorActivation: + """Activate the connector for this organization. + + Refused when the connector is gated by a BOOLEAN entitlement this organization's + license does not grant. + """ + return self._api.send( + "PUT", api_path("connectors", connector_name, "activation"), ConnectorActivation + ) + + def deactivate(self, connector_name: str) -> None: + """Deactivate the connector for this organization.""" + self._api.send_empty("DELETE", api_path("connectors", connector_name, "activation")) + + def get_settings_schema(self, connector_name: str) -> ConnectorSettingsSchema: + """Return the JSON schema the connector's settings are validated against.""" + return self._api.get( + api_path("connectors", connector_name, "settings", "schema"), ConnectorSettingsSchema + ) + + def get_settings(self, connector_name: str) -> ConnectorSettings: + """Return this organization's settings for the connector. + + Secrets -- the ``writeOnly`` properties of the settings schema -- are never read back. + """ + return self._api.get(api_path("connectors", connector_name, "settings"), ConnectorSettings) + + def update_settings( + self, connector_name: str, settings: Mapping[str, Any] + ) -> ConnectorSettings: + """Replace this organization's settings for the connector, and return them. + + The settings are validated against the connector's settings schema. + """ + return self._api.send( + "PUT", + api_path("connectors", connector_name, "settings"), + ConnectorSettings, + body={"settings": dict(settings)}, + ) + + def delete_settings(self, connector_name: str) -> None: + """Delete this organization's settings for the connector.""" + self._api.send_empty("DELETE", api_path("connectors", connector_name, "settings")) diff --git a/src/kaitencloud/_sync/resources/customers.py b/src/kaitencloud/_sync/resources/customers.py new file mode 100644 index 0000000..b17eac3 --- /dev/null +++ b/src/kaitencloud/_sync/resources/customers.py @@ -0,0 +1,144 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/customers.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from ..._utils import api_path, compact, integration_body, integrations_body +from ...types import Customer, CustomerIntegration, IntegrationParam +from ._base import Resource + +__all__ = ["Customers"] + + +class Customers(Resource): + """Customers: the end clients of your product, each owning its instances.""" + + def list(self) -> builtins.list[Customer]: + """Return every customer, walking all pages.""" + return self._api.list_all("/customers", Customer) + + def get(self, customer_slug: str) -> Customer: + """Return the customer identified by ``customer_slug``.""" + return self._api.get(api_path("customers", customer_slug), Customer) + + def create( + self, + *, + name: str, + slug: str | None = None, + external_customer_id: str | None = None, + domain: str | None = None, + integrations: Mapping[str, IntegrationParam] | None = None, + ) -> Customer: + """Create a customer. + + Args: + name: The customer's name. + slug: Its URL-friendly identifier, unique in the organization. Generated from the + name when omitted, and immutable once set. + external_customer_id: Its identifier in your own systems, e.g. your CRM. + domain: Its domain name, e.g. ``"acme.com"``. + integrations: Links to records in third-party systems, keyed by adapter name. + Only settable here: afterwards, use :meth:`update_integration`. + + Raises: + ConflictError: The slug is taken, or the organization reached its customer limit. + """ + body = compact( + { + "name": name, + "slug": slug, + "externalCustomerId": external_customer_id, + "domain": domain, + "integrations": integrations_body(integrations), + } + ) + return self._api.send("POST", "/customers", Customer, body=body) + + def update( + self, + customer_slug: str, + *, + name: str, + external_customer_id: str | None = None, + domain: str | None = None, + ) -> None: + """Replace a customer's details. + + A full replacement: the external customer id and the domain are cleared when left out. + The slug cannot be changed, and integrations have their own methods. + """ + body = compact({"name": name, "externalCustomerId": external_customer_id, "domain": domain}) + self._api.send_empty("PUT", api_path("customers", customer_slug), body=body) + + def delete(self, customer_slug: str) -> None: + """Delete the customer identified by ``customer_slug``.""" + self._api.send_empty("DELETE", api_path("customers", customer_slug)) + + def get_integration(self, customer_slug: str, integration_name: str) -> CustomerIntegration: + """Return the customer's link to the ``integration_name`` adapter.""" + return self._api.get( + api_path("customers", customer_slug, "integrations", integration_name), + CustomerIntegration, + ) + + def create_integration( + self, + customer_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> CustomerIntegration: + """Link the customer to a record in the ``integration_name`` adapter. + + Args: + customer_slug: The customer to link. + integration_name: The adapter, e.g. ``"attio"``. + external_id: The record's identifier in the third-party system. + metadata: Adapter-specific metadata. + web_url: An absolute http(s) link to the record. + last_error: The last synchronization error, if any. + """ + return self._api.send( + "POST", + api_path("customers", customer_slug, "integrations", integration_name), + CustomerIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + def update_integration( + self, + customer_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> CustomerIntegration: + """Replace the customer's link to the ``integration_name`` adapter, and return it.""" + return self._api.send( + "PUT", + api_path("customers", customer_slug, "integrations", integration_name), + CustomerIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + def delete_integration(self, customer_slug: str, integration_name: str) -> None: + """Remove the customer's link to the ``integration_name`` adapter.""" + self._api.send_empty( + "DELETE", api_path("customers", customer_slug, "integrations", integration_name) + ) diff --git a/src/kaitencloud/_sync/resources/deployment_zones.py b/src/kaitencloud/_sync/resources/deployment_zones.py new file mode 100644 index 0000000..e735014 --- /dev/null +++ b/src/kaitencloud/_sync/resources/deployment_zones.py @@ -0,0 +1,93 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/deployment_zones.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from ..._utils import api_path, compact +from ...types import DeploymentZone +from ._base import Resource + +__all__ = ["DeploymentZones"] + + +class DeploymentZones(Resource): + """Deployment zones: the environments your instances run in, and the release on each.""" + + def list(self) -> builtins.list[DeploymentZone]: + """Return every deployment zone, walking all pages.""" + return self._api.list_all("/deployment-zones", DeploymentZone) + + def get(self, deployment_zone_slug: str) -> DeploymentZone: + """Return the deployment zone identified by ``deployment_zone_slug``.""" + return self._api.get(api_path("deployment-zones", deployment_zone_slug), DeploymentZone) + + def create( + self, + *, + name: str, + type: str, + description: str = "", + metadata: Mapping[str, Any] | None = None, + release_id: str | None = None, + slug: str | None = None, + ) -> DeploymentZone: + """Create a deployment zone. + + Args: + name: The zone's name, e.g. ``"AWS eu-west-1"``. + type: Its environment class. Free-form, but ``production``, ``staging`` and + ``development`` are the ones the console labels. + description: A description. + metadata: Typed metadata, validated against the organization's ``DEPLOYMENT_ZONE`` + metadata fields. + release_id: The id of the release currently deployed to the zone. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = compact( + { + "name": name, + "type": type, + "description": description, + "metadata": dict(metadata) if metadata is not None else None, + "releaseId": release_id, + "slug": slug, + } + ) + return self._api.send("POST", "/deployment-zones", DeploymentZone, body=body) + + def update( + self, + deployment_zone_slug: str, + *, + name: str, + type: str, + description: str = "", + metadata: Mapping[str, Any] | None = None, + release_id: str | None = None, + ) -> None: + """Replace a deployment zone's details. + + A full replacement: send back the metadata and release to keep them. Setting a new + ``release_id`` records a deployment of that release to the zone. The slug cannot be + changed. + """ + body = compact( + { + "name": name, + "type": type, + "description": description, + "metadata": dict(metadata) if metadata is not None else None, + "releaseId": release_id, + } + ) + self._api.send_empty("PUT", api_path("deployment-zones", deployment_zone_slug), body=body) + + def delete(self, deployment_zone_slug: str) -> None: + """Delete the deployment zone identified by ``deployment_zone_slug``.""" + self._api.send_empty("DELETE", api_path("deployment-zones", deployment_zone_slug)) diff --git a/src/kaitencloud/_sync/resources/entitlement_groups.py b/src/kaitencloud/_sync/resources/entitlement_groups.py new file mode 100644 index 0000000..3a3406d --- /dev/null +++ b/src/kaitencloud/_sync/resources/entitlement_groups.py @@ -0,0 +1,91 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/entitlement_groups.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins + +from ..._utils import api_path, compact +from ...types import EntitlementGroup, EntitlementGroupUsage +from ._base import Resource + +__all__ = ["EntitlementGroups"] + + +class EntitlementGroups(Resource): + """Entitlement groups: named sets of entitlements, for display and for shared quotas.""" + + def list(self) -> builtins.list[EntitlementGroup]: + """Return every entitlement group, walking all pages.""" + return self._api.list_all("/entitlement-groups", EntitlementGroup) + + def get(self, entitlement_group_slug: str) -> EntitlementGroup: + """Return the entitlement group identified by ``entitlement_group_slug``.""" + return self._api.get( + api_path("entitlement-groups", entitlement_group_slug), EntitlementGroup + ) + + def create( + self, + *, + name: str, + description: str | None = None, + slug: str | None = None, + ) -> EntitlementGroup: + """Create an entitlement group. + + Args: + name: The group's name, e.g. ``"AI quotas"``. + description: A description. Always sent -- as null when omitted -- because the + API requires the key. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = {"name": name, "description": description, **compact({"slug": slug})} + return self._api.send("POST", "/entitlement-groups", EntitlementGroup, body=body) + + def update( + self, + entitlement_group_slug: str, + *, + name: str, + description: str | None = None, + ) -> None: + """Replace an entitlement group's name and description. The slug cannot be changed.""" + self._api.send_empty( + "PUT", + api_path("entitlement-groups", entitlement_group_slug), + body={"name": name, "description": description}, + ) + + def delete(self, entitlement_group_slug: str) -> None: + """Delete the entitlement group identified by ``entitlement_group_slug``.""" + self._api.send_empty("DELETE", api_path("entitlement-groups", entitlement_group_slug)) + + def add_entitlement(self, entitlement_group_slug: str, entitlement_slug: str) -> None: + """Add the entitlement identified by ``entitlement_slug`` to the group.""" + self._api.send_empty( + "POST", + api_path("entitlement-groups", entitlement_group_slug, "entitlements"), + body={"entitlementSlug": entitlement_slug}, + ) + + def remove_entitlement(self, entitlement_group_slug: str, entitlement_slug: str) -> None: + """Remove the entitlement identified by ``entitlement_slug`` from the group.""" + self._api.send_empty( + "DELETE", + api_path( + "entitlement-groups", entitlement_group_slug, "entitlements", entitlement_slug + ), + ) + + def get_usage( + self, entitlement_group_slug: str, instance_slug: str + ) -> builtins.list[EntitlementGroupUsage]: + """Return the grant and the usage of each of the group's entitlements, for one instance.""" + return self._api.get_list( + api_path("entitlement-groups", entitlement_group_slug, "usage"), + EntitlementGroupUsage, + params={"instance": instance_slug}, + ) diff --git a/src/kaitencloud/_sync/resources/entitlements.py b/src/kaitencloud/_sync/resources/entitlements.py new file mode 100644 index 0000000..3139af4 --- /dev/null +++ b/src/kaitencloud/_sync/resources/entitlements.py @@ -0,0 +1,204 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/entitlements.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from typing import Any + +from ..._utils import api_path, compact +from ...types import AggregationMethod, Entitlement, EntitlementType, ResetAnchor, ResetPeriod +from ._base import Resource + +__all__ = ["Entitlements"] + + +class Entitlements(Resource): + """Entitlements: the catalog of what a license can grant, and how usage is measured. + + An entitlement says *what* can be measured. How *much* a license grants of it is set on + the license (``client.licenses.associate_entitlement``), and how much an instance has + consumed is reported on the instance (``client.instances.report_usage``). + """ + + def list(self) -> builtins.list[Entitlement]: + """Return every entitlement, walking all pages.""" + return self._api.list_all("/entitlements", Entitlement) + + def get(self, entitlement_slug: str) -> Entitlement: + """Return the entitlement identified by ``entitlement_slug``.""" + return self._api.get(api_path("entitlements", entitlement_slug), Entitlement) + + def create( + self, + *, + name: str, + type: EntitlementType | None = None, + description: str | None = None, + slug: str | None = None, + aggregation_method: AggregationMethod | None = None, + reset_period: ResetPeriod | None = None, + reset_anchor: ResetAnchor | None = None, + warning_threshold_percent: int | None = None, + group_slugs: Sequence[str] | None = None, + user_facing: bool | None = None, + display_order: int | None = None, + icon: str | None = None, + unit_singular: str | None = None, + unit_plural: str | None = None, + sale_unit_singular: str | None = None, + sale_unit_plural: str | None = None, + sale_unit_factor: float | None = None, + ) -> Entitlement: + """Create an entitlement. + + Args: + name: The entitlement's name, e.g. ``"Seats"``. + type: ``BOOLEAN`` (a feature), ``NUMBER`` or ``NUMBER_AI_CREDIT`` (a metered quota) + or ``CONFIG`` (a configuration value). + description: A description. Always sent -- as null when omitted -- because the + API requires the key. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + aggregation_method: How each reported value folds into the total, for a NUMBER + entitlement: ``SUM`` (the default), ``COUNT``, ``AVERAGE``, ``MIN``, ``MAX`` or + ``LATEST``. + reset_period: How often usage resets (``HOUR`` to ``YEAR``). Omitted, usage is a + lifetime counter. Once set it can never be changed or removed. + reset_anchor: What the reset windows align on: ``CALENDAR`` (the default) or + ``LICENSE_START``. Immutable once set. + warning_threshold_percent: The share of the cap, from 0 to 100, at which a warning + event fires. 0 disables it. + group_slugs: The groups the entitlement belongs to. + user_facing: Whether customer-facing components (pricing tables, usage meters) + show it. Defaults to ``False``, which keeps internal counters out of them. + display_order: Its position in customer-facing components, ascending. + icon: A provider-namespaced icon token, e.g. ``"lucide:users"``. + unit_singular: The base unit's singular label, e.g. ``"seat"``. Set both labels or + neither. + unit_plural: The base unit's plural label, e.g. ``"seats"``. + sale_unit_singular: The label of the unit it is sold in, when that differs from the + base unit, e.g. ``"pack"``. Requires the base unit labels and the full sale + unit trio. + sale_unit_plural: The sale unit's plural label. + sale_unit_factor: How many base units one sale unit holds, e.g. ``1_000_000``. + """ + body = { + "name": name, + "description": description, + **compact( + { + "slug": slug, + **_fields( + type=type, + aggregation_method=aggregation_method, + reset_period=reset_period, + reset_anchor=reset_anchor, + warning_threshold_percent=warning_threshold_percent, + group_slugs=group_slugs, + user_facing=user_facing, + display_order=display_order, + icon=icon, + unit_singular=unit_singular, + unit_plural=unit_plural, + sale_unit_singular=sale_unit_singular, + sale_unit_plural=sale_unit_plural, + sale_unit_factor=sale_unit_factor, + ), + } + ), + } + return self._api.send("POST", "/entitlements", Entitlement, body=body) + + def update( + self, + entitlement_slug: str, + *, + name: str, + type: EntitlementType | None = None, + description: str | None = None, + aggregation_method: AggregationMethod | None = None, + reset_period: ResetPeriod | None = None, + reset_anchor: ResetAnchor | None = None, + warning_threshold_percent: int | None = None, + group_slugs: Sequence[str] | None = None, + user_facing: bool | None = None, + display_order: int | None = None, + icon: str | None = None, + unit_singular: str | None = None, + unit_plural: str | None = None, + sale_unit_singular: str | None = None, + sale_unit_plural: str | None = None, + sale_unit_factor: float | None = None, + ) -> None: + """Replace an entitlement's definition. + + A full replacement: optional fields left out are reset. ``type``, + ``aggregation_method``, ``reset_period`` and ``reset_anchor`` are immutable once set -- + send the stored values back, as the API refuses a different one. The slug cannot be + changed. See :meth:`create` for what each field means. + """ + body = { + "name": name, + "description": description, + **compact( + _fields( + type=type, + aggregation_method=aggregation_method, + reset_period=reset_period, + reset_anchor=reset_anchor, + warning_threshold_percent=warning_threshold_percent, + group_slugs=group_slugs, + user_facing=user_facing, + display_order=display_order, + icon=icon, + unit_singular=unit_singular, + unit_plural=unit_plural, + sale_unit_singular=sale_unit_singular, + sale_unit_plural=sale_unit_plural, + sale_unit_factor=sale_unit_factor, + ) + ), + } + self._api.send_empty("PUT", api_path("entitlements", entitlement_slug), body=body) + + def delete(self, entitlement_slug: str) -> None: + """Delete the entitlement identified by ``entitlement_slug``.""" + self._api.send_empty("DELETE", api_path("entitlements", entitlement_slug)) + + +def _fields( + *, + type: EntitlementType | None, + aggregation_method: AggregationMethod | None, + reset_period: ResetPeriod | None, + reset_anchor: ResetAnchor | None, + warning_threshold_percent: int | None, + group_slugs: Sequence[str] | None, + user_facing: bool | None, + display_order: int | None, + icon: str | None, + unit_singular: str | None, + unit_plural: str | None, + sale_unit_singular: str | None, + sale_unit_plural: str | None, + sale_unit_factor: float | None, +) -> dict[str, Any]: + return { + "type": type, + "aggregationMethod": aggregation_method, + "resetPeriod": reset_period, + "resetAnchor": reset_anchor, + "warningThresholdPercent": warning_threshold_percent, + "groupSlugs": list(group_slugs) if group_slugs is not None else None, + "userFacing": user_facing, + "displayOrder": display_order, + "icon": icon, + "unitSingular": unit_singular, + "unitPlural": unit_plural, + "saleUnitSingular": sale_unit_singular, + "saleUnitPlural": sale_unit_plural, + "saleUnitFactor": sale_unit_factor, + } diff --git a/src/kaitencloud/_sync/resources/feature_flags.py b/src/kaitencloud/_sync/resources/feature_flags.py new file mode 100644 index 0000000..dd4c2e1 --- /dev/null +++ b/src/kaitencloud/_sync/resources/feature_flags.py @@ -0,0 +1,166 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/feature_flags.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from ..._utils import api_path +from ...types import ( + DefaultVariantParam, + FeatureFlag, + FeatureFlagType, + ManifestEnvelope, + ManifestFlag, + TargetingParam, + VariantParam, +) +from ._base import Resource + +__all__ = ["FeatureFlags"] + + +class FeatureFlags(Resource): + """Feature flags: variants, targeting rules and default variants. + + This namespace manages flag definitions. To evaluate a flag for a context, use + ``client.flags``. The builders in :mod:`kaitencloud.targeting` write variants and rules. + """ + + def list(self) -> builtins.list[FeatureFlag]: + """Return every feature flag, walking all pages.""" + return self._api.list_all("/feature-flags", FeatureFlag) + + def get(self, feature_flag_slug: str) -> FeatureFlag: + """Return the feature flag identified by ``feature_flag_slug``.""" + return self._api.get(api_path("feature-flags", feature_flag_slug), FeatureFlag) + + def create( + self, + *, + name: str, + type: FeatureFlagType, + variants: Sequence[VariantParam], + default_variant: str | DefaultVariantParam, + targetings: Sequence[TargetingParam] = (), + enabled: bool = True, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + event_name: str = "", + slug: str | None = None, + ) -> FeatureFlag: + """Create a feature flag. + + Args: + name: The flag's name. + type: The type of its variants' values: ``boolean``, ``string``, ``number`` or + ``object``. + variants: Its possible values, e.g. ``[targeting.variant("on", True), + targeting.variant("off", False)]``. + default_variant: What it serves when no rule matches: a variant name, or a + rollout from :func:`kaitencloud.targeting.split` or + :func:`kaitencloud.targeting.schedule`. + targetings: Its targeting rules, evaluated in order; the first match wins. + enabled: The kill switch. A disabled flag serves its default variant to everyone + and evaluates no rule. + description: A description. + metadata: Free-form metadata. + event_name: The analytics event associated with the flag, if any. + slug: Its URL-friendly identifier, generated when omitted. Doubles as the key the + flag is evaluated by. + """ + body = _flag( + name=name, + type=type, + variants=variants, + default_variant=default_variant, + targetings=targetings, + enabled=enabled, + description=description, + metadata=metadata, + event_name=event_name, + ) + if slug is not None: + body["slug"] = slug + return self._api.send("POST", "/feature-flags", FeatureFlag, body=body) + + def update( + self, + feature_flag_slug: str, + *, + name: str, + type: FeatureFlagType, + variants: Sequence[VariantParam], + default_variant: str | DefaultVariantParam, + targetings: Sequence[TargetingParam] = (), + enabled: bool = True, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + event_name: str = "", + ) -> None: + """Replace a feature flag's definition. + + A full replacement: the variants, rules and metadata sent become the flag's whole + definition. See :meth:`create` for what each field means. + """ + body = _flag( + name=name, + type=type, + variants=variants, + default_variant=default_variant, + targetings=targetings, + enabled=enabled, + description=description, + metadata=metadata, + event_name=event_name, + ) + self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body) + + def delete(self, feature_flag_slug: str) -> None: + """Delete the feature flag identified by ``feature_flag_slug``.""" + self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug)) + + def manifest(self) -> builtins.list[ManifestFlag]: + """Return the OpenFeature manifest: every flag with its type and default value.""" + envelope = self._api.get("/openfeature/v0/manifest", ManifestEnvelope) + return builtins.list(envelope.flags or []) + + +def _flag( + *, + name: str, + type: FeatureFlagType, + variants: Sequence[VariantParam], + default_variant: str | DefaultVariantParam, + targetings: Sequence[TargetingParam], + enabled: bool, + description: str | None, + metadata: Mapping[str, Any] | None, + event_name: str, +) -> dict[str, Any]: + return { + "name": name, + "type": type, + "variants": [ + { + "name": variant["name"], + "description": variant.get("description", ""), + "value": variant["value"], + } + for variant in variants + ], + "default_variant": ( + {"type": "basic", "value": default_variant} + if isinstance(default_variant, str) + else dict(default_variant) + ), + "targetings": [dict(targeting) for targeting in targetings], + "enabled": enabled, + "description": description, + "metadata": dict(metadata) if metadata is not None else {}, + "event_name": event_name, + } diff --git a/src/kaitencloud/_sync/resources/instances.py b/src/kaitencloud/_sync/resources/instances.py new file mode 100644 index 0000000..ecae47d --- /dev/null +++ b/src/kaitencloud/_sync/resources/instances.py @@ -0,0 +1,349 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/instances.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from ..._exceptions import ThresholdExceededError +from ..._utils import ( + api_path, + compact, + format_datetime, + integration_body, + integrations_body, + optional_datetime, +) +from ...types import ( + AuditTrail, + EntitlementUsage, + Instance, + InstanceIntegration, + InstanceStatus, + IntegrationParam, + UsageBehavior, +) +from ._base import Resource + +__all__ = ["Instances"] + + +class Instances(Resource): + """Instances: deployments of your product for a customer, under a license. + + An instance is where entitlements meet reality: usage is reported against it, and its + audit trail records what happened to it. + """ + + def list(self) -> builtins.list[Instance]: + """Return every instance, walking all pages.""" + return self._api.list_all("/instances", Instance) + + def get(self, instance_slug: str) -> Instance: + """Return the instance identified by ``instance_slug``.""" + return self._api.get(api_path("instances", instance_slug), Instance) + + def create( + self, + *, + name: str, + customer_id: str, + license_id: str, + start_license_date: datetime | str, + end_license_date: datetime | str, + description: str = "", + deployment_zone_id: str | None = None, + metadata: Mapping[str, Any] | None = None, + slug: str | None = None, + integrations: Mapping[str, IntegrationParam] | None = None, + ) -> Instance: + """Create an instance. + + Args: + name: The instance's name. + customer_id: The id -- not the slug -- of the customer it is deployed for. + license_id: The id of the license it runs under. + start_license_date: When its license term starts: a timezone-aware datetime, or an + RFC 3339 string. + end_license_date: When its license term ends. + description: A description. + deployment_zone_id: The id of the deployment zone it runs in. + metadata: Typed metadata, validated against the organization's ``INSTANCE`` + metadata fields. + slug: Its URL-friendly identifier, generated when omitted. + integrations: Links to records in third-party systems, keyed by adapter name. + Only settable here: afterwards, use :meth:`update_integration`. + + Raises: + NotFoundError: The customer, license or deployment zone does not exist in this + organization. + ConflictError: The slug is taken, or the organization reached its instance limit. + """ + body = { + **_instance( + name=name, + customer_id=customer_id, + license_id=license_id, + start_license_date=start_license_date, + end_license_date=end_license_date, + description=description, + deployment_zone_id=deployment_zone_id, + metadata=metadata, + ), + **compact({"slug": slug, "integrations": integrations_body(integrations)}), + } + return self._api.send("POST", "/instances", Instance, body=body) + + def update( + self, + instance_slug: str, + *, + name: str, + customer_id: str, + license_id: str, + start_license_date: datetime | str, + end_license_date: datetime | str, + description: str = "", + deployment_zone_id: str | None = None, + metadata: Mapping[str, Any] | None = None, + slug: str | None = None, + ) -> None: + """Replace an instance's details. + + A full replacement, with two fields where leaving a value out keeps the current one: + ``deployment_zone_id`` and ``slug``. Passing a ``slug`` renames the instance. + ``metadata`` replaces the whole blob, so send back the keys to keep. + + Moving an instance into a deployment zone, or to another one, is how a deployment or a + migration is recorded: there is no separate call for either. + """ + body = { + **_instance( + name=name, + customer_id=customer_id, + license_id=license_id, + start_license_date=start_license_date, + end_license_date=end_license_date, + description=description, + deployment_zone_id=deployment_zone_id, + metadata=metadata, + ), + **compact({"slug": slug}), + } + self._api.send_empty("PUT", api_path("instances", instance_slug), body=body) + + def update_status(self, instance_slug: str, status: InstanceStatus) -> None: + """Set an instance's operational status, leaving everything else untouched. + + ``status`` is ``HEALTHY``, ``DEGRADED``, ``INCIDENT`` or ``MAINTENANCE``. + """ + self._api.send_empty( + "PATCH", api_path("instances", instance_slug), body={"status": status}, idempotent=True + ) + + def update_lifecycle_stage(self, instance_slug: str, lifecycle_stage: str) -> None: + """Set an instance's commercial lifecycle stage, leaving everything else untouched. + + Free-form; ``TRIAL``, ``ACTIVE``, ``AT_RISK`` and ``CHURNED`` are the suggested stages. + """ + self._api.send_empty( + "PATCH", + api_path("instances", instance_slug), + body={"lifecycleStage": lifecycle_stage}, + idempotent=True, + ) + + def delete(self, instance_slug: str) -> None: + """Delete the instance identified by ``instance_slug``. The deletion is permanent.""" + self._api.send_empty("DELETE", api_path("instances", instance_slug)) + + def list_audit_trails( + self, + instance_slug: str, + *, + event_name: str | None = None, + after: datetime | str | None = None, + before: datetime | str | None = None, + limit: int | None = None, + ) -> builtins.list[AuditTrail]: + """Return an instance's audit trail entries. + + The audit trail grows without bound, so ``limit`` is a ceiling rather than a page + size: pages are walked until that many entries are held, and no page asks for more + than are still wanted. Without a limit, every entry is returned. + + Args: + instance_slug: The instance whose audit trail to read. + event_name: Only entries of this event, e.g. ``"INSTANCE_STATUS_CHANGED"``. + after: Only entries at or after this moment. + before: Only entries at or before this moment. + limit: The most entries to return. + """ + if limit is not None and limit < 1: + raise ValueError(f"limit must be at least 1, got {limit}") + return self._api.list_all( + api_path("instances", instance_slug, "audit-trails"), + AuditTrail, + params={ + "event_name": event_name, + "after": optional_datetime(after), + "before": optional_datetime(before), + }, + limit=limit, + ) + + def list_usage(self, instance_slug: str) -> builtins.list[EntitlementUsage]: + """Return the instance's usage of every entitlement, each next to the grant it counts against.""" + return self._api.get_list( + api_path("instances", instance_slug, "entitlements", "usage"), EntitlementUsage + ) + + def get_usage(self, instance_slug: str, entitlement_slug: str) -> EntitlementUsage: + """Return the instance's usage of one entitlement, next to the grant it counts against. + + For a periodic entitlement, ``current_period_start`` and ``current_period_end`` bound + the window the usage belongs to; both are ``None`` for a lifetime counter. Each read is + recorded as an ``ENTITLEMENT_VALUE_GET`` event. + """ + return self._api.get( + api_path("instances", instance_slug, "entitlements", entitlement_slug, "usage"), + EntitlementUsage, + ) + + def report_usage( + self, + instance_slug: str, + entitlement_slug: str, + value: float, + *, + behavior: UsageBehavior = "append", + metadata: Mapping[str, Any] | None = None, + ) -> EntitlementUsage: + """Report usage of a NUMBER entitlement, and return the usage it results in. + + Args: + instance_slug: The instance that consumed. + entitlement_slug: The entitlement consumed. + value: The amount. With ``behavior="append"`` it is folded into the stored total + through the entitlement's aggregation method (a sum, by default). With + ``"set"`` it replaces the total -- which is also how to correct it downwards. + behavior: ``"append"`` (the default) or ``"set"``. + metadata: Free-form metadata recorded with the report. + + Raises: + ThresholdExceededError: The report would take usage past the maximum the license + allows -- the granted value plus its overage allowance. Nothing was recorded. + + Never retried automatically: without an idempotency key on the wire, a report retried + after a lost response would be counted twice. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"usage must be a number, got {type(value).__name__}") + body = compact( + { + "value": {"type": "number", "value": value}, + "behavior": behavior, + "metadata": dict(metadata) if metadata is not None else None, + } + ) + return self._api.send( + "POST", + api_path("instances", instance_slug, "entitlements", entitlement_slug, "usage"), + EntitlementUsage, + body=body, + errors={409: ThresholdExceededError}, + ) + + def get_integration(self, instance_slug: str, integration_name: str) -> InstanceIntegration: + """Return the instance's link to the ``integration_name`` adapter.""" + return self._api.get( + api_path("instances", instance_slug, "integrations", integration_name), + InstanceIntegration, + ) + + def create_integration( + self, + instance_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> InstanceIntegration: + """Link the instance to a record in the ``integration_name`` adapter. + + Args: + instance_slug: The instance to link. + integration_name: The adapter, e.g. ``"attio"``. + external_id: The record's identifier in the third-party system. + metadata: Adapter-specific metadata. + web_url: An absolute http(s) link to the record. + last_error: The last synchronization error, if any. + """ + return self._api.send( + "POST", + api_path("instances", instance_slug, "integrations", integration_name), + InstanceIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + def update_integration( + self, + instance_slug: str, + integration_name: str, + *, + external_id: str, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + last_error: str | None = None, + ) -> InstanceIntegration: + """Replace the instance's link to the ``integration_name`` adapter, and return it.""" + return self._api.send( + "PUT", + api_path("instances", instance_slug, "integrations", integration_name), + InstanceIntegration, + body=integration_body( + external_id=external_id, metadata=metadata, web_url=web_url, last_error=last_error + ), + ) + + def delete_integration(self, instance_slug: str, integration_name: str) -> None: + """Remove the instance's link to the ``integration_name`` adapter.""" + self._api.send_empty( + "DELETE", api_path("instances", instance_slug, "integrations", integration_name) + ) + + +def _instance( + *, + name: str, + customer_id: str, + license_id: str, + start_license_date: datetime | str, + end_license_date: datetime | str, + description: str, + deployment_zone_id: str | None, + metadata: Mapping[str, Any] | None, +) -> dict[str, Any]: + return { + "name": name, + "description": description, + "customerId": customer_id, + "licenseId": license_id, + "startLicenseDate": format_datetime(start_license_date), + "endLicenseDate": format_datetime(end_license_date), + **compact( + { + "deploymentZoneId": deployment_zone_id, + "metadata": dict(metadata) if metadata is not None else None, + } + ), + } diff --git a/src/kaitencloud/_sync/resources/integrations.py b/src/kaitencloud/_sync/resources/integrations.py new file mode 100644 index 0000000..b30bd64 --- /dev/null +++ b/src/kaitencloud/_sync/resources/integrations.py @@ -0,0 +1,145 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/integrations.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from ..._utils import api_path, compact, optional_datetime +from ...types import CustomerIntegrationResource, InstanceIntegrationResource +from ._base import Resource + +__all__ = ["Integrations"] + + +class Integrations(Resource): + """Customers and instances, addressed by their identifier in a third-party system. + + This is the surface a connector synchronizes through: it knows a record's id in *its* + system -- an Attio or HubSpot record -- not the Kaiten slug. ``adapter`` names that system, + without the ``kaiten.integration.`` prefix. + """ + + def get_customer(self, adapter: str, external_id: str) -> CustomerIntegrationResource: + """Return the customer linked to ``external_id`` in ``adapter``.""" + return self._api.get( + api_path("integration", adapter, "customer", external_id), CustomerIntegrationResource + ) + + def upsert_customer( + self, + adapter: str, + external_id: str, + *, + name: str | None = None, + slug: str | None = None, + domain: str | None = None, + web_url: str | None = None, + error: str | None = None, + integration_metadata: Mapping[str, Any] | None = None, + ) -> CustomerIntegrationResource: + """Create or update the customer linked to ``external_id`` in ``adapter``. + + The customer is created -- subject to the organization's customer limit -- when no + customer is linked to that record yet, and updated otherwise. + + Args: + adapter: The third-party system, e.g. ``"attio"``. + external_id: The record's identifier in that system. + name: The customer's name. + slug: The customer's slug. + domain: The customer's domain name. + web_url: An absolute http(s) link to the record. + error: The synchronization error to record, if any. + integration_metadata: Adapter-specific metadata. + """ + body = compact( + { + "name": name, + "slug": slug, + "domain": domain, + "webUrl": web_url, + "error": error, + "integrationMetadata": ( + dict(integration_metadata) if integration_metadata is not None else None + ), + } + ) + return self._api.send( + "PATCH", + api_path("integration", adapter, "customer", external_id), + CustomerIntegrationResource, + body=body, + ) + + def get_instance(self, adapter: str, external_id: str) -> InstanceIntegrationResource: + """Return the instance linked to ``external_id`` in ``adapter``.""" + return self._api.get( + api_path("integration", adapter, "instance", external_id), InstanceIntegrationResource + ) + + def upsert_instance( + self, + adapter: str, + external_id: str, + *, + name: str | None = None, + slug: str | None = None, + description: str | None = None, + customer_external_id: str | None = None, + license_id: str | None = None, + deployment_zone_id: str | None = None, + start_license_date: datetime | str | None = None, + end_license_date: datetime | str | None = None, + metadata: Mapping[str, Any] | None = None, + web_url: str | None = None, + error: str | None = None, + integration_metadata: Mapping[str, Any] | None = None, + ) -> InstanceIntegrationResource: + """Create or update the instance linked to ``external_id`` in ``adapter``. + + Args: + adapter: The third-party system, e.g. ``"attio"``. + external_id: The record's identifier in that system. + name: The instance's name. + slug: The instance's slug. + description: The instance's description. + customer_external_id: The identifier, in the same system, of the customer the + instance belongs to. + license_id: The id of the license the instance runs under. + deployment_zone_id: The id of the deployment zone it runs in. + start_license_date: When its license term starts. + end_license_date: When its license term ends. + metadata: The instance's metadata. + web_url: An absolute http(s) link to the record. + error: The synchronization error to record, if any. + integration_metadata: Adapter-specific metadata. + """ + body = compact( + { + "name": name, + "slug": slug, + "description": description, + "customerExternalId": customer_external_id, + "licenseId": license_id, + "deploymentZoneId": deployment_zone_id, + "startLicenseDate": optional_datetime(start_license_date), + "endLicenseDate": optional_datetime(end_license_date), + "metadata": dict(metadata) if metadata is not None else None, + "webUrl": web_url, + "error": error, + "integrationMetadata": ( + dict(integration_metadata) if integration_metadata is not None else None + ), + } + ) + return self._api.send( + "PATCH", + api_path("integration", adapter, "instance", external_id), + InstanceIntegrationResource, + body=body, + ) diff --git a/src/kaitencloud/_sync/resources/license_families.py b/src/kaitencloud/_sync/resources/license_families.py new file mode 100644 index 0000000..cc2525e --- /dev/null +++ b/src/kaitencloud/_sync/resources/license_families.py @@ -0,0 +1,63 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/license_families.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from typing import Any + +from ..._utils import api_path +from ...types import LicenseFamilyView +from ._base import Resource + +__all__ = ["LicenseFamilies"] + + +class LicenseFamilies(Resource): + """License families: the products whose versions :attr:`KaitenClient.licenses` are. + + A family's slug survives renames and new versions, so it is the identifier worth storing: + a version's slug names the row a customer bought, and a new one appears every time a + version is published. Reading a family resolves it to the version it currently serves. + """ + + def list(self) -> builtins.list[LicenseFamilyView]: + """Return every license family, each with the version it currently serves. + + A family with nothing published -- every version a draft, or all of them archived -- + is listed too, with ``current_version`` set to ``None``. + """ + return self._api.list_all("/license-families", LicenseFamilyView) + + def get( + self, family_slug: str, *, version: int | None = None, include_versions: bool = False + ) -> LicenseFamilyView: + """Resolve the family identified by ``family_slug`` to one of its versions. + + With no options it returns, as ``current_version``, the version the family serves + today: its default version, or failing that its highest-numbered published one. That + is the point of addressing a family -- publishing a new version changes what this + returns, with no change to the caller. + + Args: + family_slug: The family's slug. + version: Read this version number instead, whatever its lifecycle state, archived + included: pinned access stays pinned. + include_versions: Also return every version of the family, oldest first, as + ``versions``. It makes a family with no published version readable too. + + Raises: + NotFoundError: No family has this slug -- or it exists but has no published + version and ``include_versions`` was not asked, which the problem's ``code`` + tells apart: ``GetLicenseFamily.NoPublishedVersion``. + """ + params: dict[str, Any] = {} + if version is not None: + params["version"] = version + if include_versions: + params["include"] = "versions" + return self._api.get( + api_path("license-families", family_slug), LicenseFamilyView, params=params or None + ) diff --git a/src/kaitencloud/_sync/resources/licenses.py b/src/kaitencloud/_sync/resources/licenses.py new file mode 100644 index 0000000..ecbc355 --- /dev/null +++ b/src/kaitencloud/_sync/resources/licenses.py @@ -0,0 +1,245 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/licenses.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from typing import Literal + +from ..._types import EntitlementValueInput +from ..._utils import api_path, compact, entitlement_value +from ...types import License, LicenseEntitlement, LicenseType +from ._base import Resource + +__all__ = ["Licenses"] + + +class Licenses(Resource): + """Licenses: the versions of a license family, each granting values of catalog entitlements. + + A license is one version of a product, the *license family*. Creating a license without + naming a family opens a new one, of which it is version 1; naming a family with + ``family_slug`` or ``family_id`` adds that family's next version. Each version moves + through a lifecycle -- ``DRAFT``, ``PUBLISHED``, ``ARCHIVED`` -- by :meth:`publish`, + :meth:`archive` and :meth:`unarchive`, and :attr:`KaitenClient.license_families` resolves + a family to the version it currently serves. + """ + + def list(self) -> builtins.list[License]: + """Return every license, walking all pages.""" + return self._api.list_all("/licenses", License) + + def get(self, license_slug: str) -> License: + """Return the license identified by ``license_slug``.""" + return self._api.get(api_path("licenses", license_slug), License) + + def create( + self, + *, + name: str, + type: LicenseType, + description: str = "", + is_default: bool = False, + version_name: str | None = None, + slug: str | None = None, + family_slug: str | None = None, + family_id: str | None = None, + lifecycle_state: Literal["DRAFT", "PUBLISHED"] | None = None, + ) -> License: + """Create a license: a new license family, or the next version of an existing one. + + Without ``family_slug`` or ``family_id`` this opens a new family, of which the license + is version 1, and the family takes the license's slug. With either, it adds the next + version to that family -- the version number is always assigned by the server, never + reused within a family, and cannot be sent. Two families may share a name: the family, + not the name, is the product. Where the deployment limits how many licenses an + organization may have, only a new family counts against that limit. + + Args: + name: The license's name, e.g. ``"Growth"``. + type: ``DEVELOPMENT``, ``TRIAL``, ``PAID`` or ``COMMUNITY``. + description: A description, up to 500 characters. + is_default: Whether this version is the one its family puts forward. Only a + ``PUBLISHED`` version can be. + version_name: A label for this version, e.g. ``"Winter 2026"``. Defaults to + ``"Version - "``. + slug: Its URL-friendly identifier. When omitted, a new version of an existing + family takes ``-v``, and a new family's first version one + generated from its name. Immutable once set. + family_slug: The family this license is a new version of, by slug. + family_id: The same family by identifier. Sent together, the two must agree. + lifecycle_state: ``DRAFT`` to create the version off sale, to be put on sale with + :meth:`publish`. ``PUBLISHED`` when omitted. A version is never created + ``ARCHIVED``: it is archived with :meth:`archive` once it has been on sale. + + Raises: + NotFoundError: ``family_slug`` or ``family_id`` names no family + (``CreateLicense.FamilyNotFound``). + ConflictError: ``is_default=True`` on a draft (``CreateLicense.DefaultMustBePublished``), + or the organization reached its license limit, which only a new family counts + against. + UnprocessableEntityError: ``family_slug`` and ``family_id`` name different families + (``CreateLicense.FamilyMismatch``). + """ + body = { + "name": name, + "description": description, + "type": type, + "isDefault": is_default, + **compact( + { + "versionName": version_name, + "slug": slug, + "familySlug": family_slug, + "familyId": family_id, + "lifecycleState": lifecycle_state, + } + ), + } + return self._api.send("POST", "/licenses", License, body=body) + + def update( + self, + license_slug: str, + *, + name: str, + type: LicenseType, + description: str = "", + is_default: bool = False, + version_name: str | None = None, + ) -> None: + """Replace a license version's details. + + A full replacement. The slug, the version number, the family and the lifecycle state + are not part of it: the first three never change, and the state moves only through + :meth:`publish`, :meth:`archive` and :meth:`unarchive`. ``is_default=True`` takes the + flag from the family's previous default, and ``False`` unsets it on this version. + + Raises: + ConflictError: ``is_default=True`` on a version that is not ``PUBLISHED`` + (``UpdateLicense.DefaultMustBePublished``): publish or unarchive it first. + """ + body = { + "name": name, + "description": description, + "type": type, + "isDefault": is_default, + **compact({"versionName": version_name}), + } + self._api.send_empty("PUT", api_path("licenses", license_slug), body=body) + + def publish(self, license_slug: str) -> License: + """Put a ``DRAFT`` version on sale, and return it ``PUBLISHED``. + + It can then be made its family's default, and a family without a default serves its + highest-numbered published version, which may be this one. + + Raises: + ConflictError: The version is not a draft (``PublishLicense.NotADraft``). An + archived version goes back on sale with :meth:`unarchive`. + """ + return self._api.send("POST", api_path("licenses", license_slug, "publish"), License) + + def archive(self, license_slug: str) -> License: + """Withdraw a ``PUBLISHED`` version from sale, and return it ``ARCHIVED``. + + Its family stops resolving to it and no instance can be assigned to it any more, while + the instances already on it keep it. + + Raises: + ConflictError: The version is not published (``ArchiveLicense.NotPublished``) -- a + draft that was never on sale is deleted instead -- or it is its family's + default (``ArchiveLicense.DefaultMustBePublished``): make another version the + default, or unset it, first. + """ + return self._api.send("POST", api_path("licenses", license_slug, "archive"), License) + + def unarchive(self, license_slug: str) -> License: + """Put an ``ARCHIVED`` version back on sale, and return it ``PUBLISHED``. + + Raises: + ConflictError: The version is not archived (``UnarchiveLicense.NotArchived``). A + draft goes on sale with :meth:`publish`. + """ + return self._api.send("POST", api_path("licenses", license_slug, "unarchive"), License) + + def delete(self, license_slug: str) -> None: + """Delete the license version identified by ``license_slug``. + + Deleting a family's last version deletes the family too, and frees its slug. A deleted + version's number is never reused by the family's next version. + """ + self._api.send_empty("DELETE", api_path("licenses", license_slug)) + + def list_entitlements(self, license_slug: str) -> builtins.list[LicenseEntitlement]: + """Return every entitlement the license grants, with its value, walking all pages.""" + return self._api.list_all( + api_path("licenses", license_slug, "entitlements"), LicenseEntitlement + ) + + def get_entitlement(self, license_slug: str, entitlement_slug: str) -> LicenseEntitlement: + """Return the value the license grants the entitlement identified by ``entitlement_slug``.""" + return self._api.get( + api_path("licenses", license_slug, "entitlements", entitlement_slug), + LicenseEntitlement, + ) + + def associate_entitlement( + self, + license_slug: str, + entitlement_slug: str, + value: EntitlementValueInput, + *, + overage_percent: int | None = None, + ) -> None: + """Grant an entitlement under the license. + + Args: + license_slug: The license that grants. + entitlement_slug: The entitlement granted. + value: What is granted: ``True`` or ``False`` for a BOOLEAN entitlement, a number + for a NUMBER one -- :data:`kaitencloud.UNLIMITED` for no cap -- and a mapping + for a CONFIG one. + overage_percent: How far usage may exceed a numeric value before reports are + refused: ``0`` makes a hard limit, ``10`` tolerates 10% over. It is ``-1`` + exactly when the value is unlimited. The server picks it when omitted. Sent as + ``limitCapExceededOveragePercent``. + + Raises: + ConflictError: The license already grants this entitlement. + """ + body = { + "entitlementSlug": entitlement_slug, + "value": entitlement_value(value), + **compact({"limitCapExceededOveragePercent": overage_percent}), + } + self._api.send_empty("POST", api_path("licenses", license_slug, "entitlements"), body=body) + + def update_entitlement( + self, + license_slug: str, + entitlement_slug: str, + value: EntitlementValueInput, + *, + overage_percent: int | None = None, + ) -> None: + """Change what the license grants an entitlement. + + A full replacement of the grant: see :meth:`associate_entitlement` for ``value`` and + ``overage_percent``. + """ + body = { + "value": entitlement_value(value), + **compact({"limitCapExceededOveragePercent": overage_percent}), + } + self._api.send_empty( + "PUT", api_path("licenses", license_slug, "entitlements", entitlement_slug), body=body + ) + + def delete_entitlement(self, license_slug: str, entitlement_slug: str) -> None: + """Stop granting the entitlement identified by ``entitlement_slug`` under the license.""" + self._api.send_empty( + "DELETE", api_path("licenses", license_slug, "entitlements", entitlement_slug) + ) diff --git a/src/kaitencloud/_sync/resources/metadata_fields.py b/src/kaitencloud/_sync/resources/metadata_fields.py new file mode 100644 index 0000000..7cdc36b --- /dev/null +++ b/src/kaitencloud/_sync/resources/metadata_fields.py @@ -0,0 +1,117 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/metadata_fields.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from ..._utils import api_path, compact +from ...types import MetadataField, MetadataFieldImpact, MetadataResourceType +from ._base import Resource + +__all__ = ["MetadataFields"] + + +class MetadataFields(Resource): + """Metadata fields: the typed fields your organization declares on instances and zones. + + Each field is a JSON Schema. The metadata of an instance or a deployment zone is validated + against the active fields declared for its resource type. + """ + + def list(self, resource_type: MetadataResourceType) -> builtins.list[MetadataField]: + """Return the metadata fields declared for ``resource_type``, walking all pages.""" + return self._api.list_all( + "/metadata-fields", MetadataField, params={"resourceType": resource_type} + ) + + def create( + self, + *, + resource_type: MetadataResourceType, + key: str, + label: str, + json_schema: Mapping[str, Any], + display_order: int | None = None, + ) -> MetadataField: + """Declare a metadata field. + + Args: + resource_type: ``INSTANCE`` or ``DEPLOYMENT_ZONE``. Immutable. + key: The metadata key the field describes, e.g. ``"region"``. Unique among the + active fields of the resource type, and immutable. + label: The label the console shows. + json_schema: A JSON Schema 2020-12 document for the value, e.g. + ``{"type": "string", "enum": ["eu", "us"]}``. + display_order: Its position in the console. Afterwards only :meth:`reorder` + changes it. + """ + body = { + "resourceType": resource_type, + "key": key, + "label": label, + "jsonSchema": dict(json_schema), + **compact({"displayOrder": display_order}), + } + return self._api.send("POST", "/metadata-fields", MetadataField, body=body) + + def update( + self, + field_id: str, + *, + resource_type: MetadataResourceType, + key: str, + label: str, + json_schema: Mapping[str, Any], + ) -> MetadataField: + """Change a metadata field's label and schema, and return the field. + + ``resource_type`` and ``key`` are immutable: send the stored values back, as the API + requires them and refuses different ones. Preview what a stricter schema would + invalidate with :meth:`dry_run` first. + """ + body = { + "resourceType": resource_type, + "key": key, + "label": label, + "jsonSchema": dict(json_schema), + } + return self._api.send( + "PATCH", api_path("metadata-fields", field_id), MetadataField, body=body + ) + + def archive(self, field_id: str) -> MetadataField: + """Archive a metadata field, which frees its key for a new one.""" + return self._api.send( + "POST", api_path("metadata-fields", field_id, "archive"), MetadataField + ) + + def unarchive(self, field_id: str) -> MetadataField: + """Restore an archived metadata field.""" + return self._api.send( + "POST", api_path("metadata-fields", field_id, "unarchive"), MetadataField + ) + + def dry_run(self, field_id: str, json_schema: Mapping[str, Any]) -> MetadataFieldImpact: + """Preview what a candidate schema would invalidate, without changing anything. + + Returns how many existing resources hold a value the candidate schema would refuse, + with a few of them as samples. + """ + return self._api.send( + "POST", + api_path("metadata-fields", field_id, "dry-run"), + MetadataFieldImpact, + body={"jsonSchema": dict(json_schema)}, + idempotent=True, + ) + + def reorder(self, field_ids: Sequence[str]) -> None: + """Set the display order of metadata fields to the order of ``field_ids``.""" + self._api.send_empty( + "POST", "/metadata-fields/reorder", body={"ids": list(field_ids)}, idempotent=True + ) diff --git a/src/kaitencloud/_sync/resources/platform/__init__.py b/src/kaitencloud/_sync/resources/platform/__init__.py new file mode 100644 index 0000000..2d43ae9 --- /dev/null +++ b/src/kaitencloud/_sync/resources/platform/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/platform/__init__.py. Do not edit by hand: +# change the async source, then run `task generate`. +from .connectors import PlatformConnectors +from .organizations import PlatformOrganizations +from .tokens import PlatformTokens +from .users import PlatformUsers + +__all__ = [ + "PlatformConnectors", + "PlatformOrganizations", + "PlatformTokens", + "PlatformUsers", +] diff --git a/src/kaitencloud/_sync/resources/platform/connectors.py b/src/kaitencloud/_sync/resources/platform/connectors.py new file mode 100644 index 0000000..955429e --- /dev/null +++ b/src/kaitencloud/_sync/resources/platform/connectors.py @@ -0,0 +1,53 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/platform/connectors.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...._utils import compact +from ....types import Connector +from .._base import Resource + +__all__ = ["PlatformConnectors"] + + +class PlatformConnectors(Resource): + """The connector registry: the connectors this deployment offers its organizations.""" + + def register( + self, + *, + name: str, + version: str, + settings_schema: Mapping[str, Any] | None = None, + entitlement_slug: str | None = None, + ) -> Connector: + """Record a connector's manifest, or update the one registered under the same name. + + An upsert on ``name``, so the process that hosts a connector can register it on every + start. Registering says the connector exists in this deployment. Whether an + organization may use it is a separate decision, which each organization makes over the + Core API and ``entitlement_slug`` gates. + + Requires a platform credential holding ``write:organizations``. + + Args: + name: The connector's stable identifier, e.g. ``"kaiten.integration.crm.attio"``. + version: The build of the connector this deployment runs. + settings_schema: The JSON schema organization settings are validated against, and + consoles render a settings form from. A ``writeOnly`` property is a secret: + accepted, never read back. Defaults to an empty schema. + entitlement_slug: The BOOLEAN entitlement a license must grant before an + organization may activate the connector. Omitted, the connector is ungated. + """ + body = { + "name": name, + "version": version, + "settings_schema": dict(settings_schema) if settings_schema is not None else {}, + **compact({"entitlement_slug": entitlement_slug}), + } + return self._api.send("POST", "/platform/connectors", Connector, body=body, idempotent=True) diff --git a/src/kaitencloud/_sync/resources/platform/organizations.py b/src/kaitencloud/_sync/resources/platform/organizations.py new file mode 100644 index 0000000..214937c --- /dev/null +++ b/src/kaitencloud/_sync/resources/platform/organizations.py @@ -0,0 +1,70 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/platform/organizations.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +from ...._utils import api_path, compact, validate_uuid +from ....types import Organization +from .._base import Resource + +__all__ = ["PlatformOrganizations"] + + +class PlatformOrganizations(Resource): + """Organizations: the tenants of this deployment.""" + + def ensure(self, *, external_id: str, name: str | None = None) -> Organization: + """Create the organization for an identity provider's organization id, or return it. + + The organization's id is derived from ``external_id``, so the same call converges on + the same organization every time -- including one a login already created just in + time. That is what makes it safe to call from a bootstrap on every run. + + Args: + external_id: The identity provider's id for the organization, e.g. + ``"org_2abcDEF"``. + name: Its display name, applied only when this call creates it: an existing + organization is never renamed. Omitted, a new organization is named after its + external id. + """ + return self._api.send( + "POST", + "/platform/organizations", + Organization, + body=compact({"externalId": external_id, "name": name}), + idempotent=True, + ) + + def get(self, organization_id: str) -> Organization: + """Return the organization identified by ``organization_id``.""" + return self._api.get(_organization(organization_id), Organization) + + def delete(self, organization_id: str) -> None: + """Delete an organization, and with it everything it owns. Irreversible. + + Requires ``delete:organizations``, which ``write:organizations`` does not imply. + """ + self._api.send_empty("DELETE", _organization(organization_id)) + + def delete_membership(self, organization_id: str, user_id: str) -> None: + """Remove one user's membership of an organization. + + Requires ``delete:memberships``: a narrower privilege than deleting the organization + or the user. + """ + self._api.send_empty( + "DELETE", + api_path( + "platform", + "organizations", + validate_uuid(organization_id, "organization_id"), + "memberships", + validate_uuid(user_id, "user_id"), + ), + ) + + +def _organization(organization_id: str) -> str: + return api_path("platform", "organizations", validate_uuid(organization_id, "organization_id")) diff --git a/src/kaitencloud/_sync/resources/platform/tokens.py b/src/kaitencloud/_sync/resources/platform/tokens.py new file mode 100644 index 0000000..1f7c5a4 --- /dev/null +++ b/src/kaitencloud/_sync/resources/platform/tokens.py @@ -0,0 +1,94 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/platform/tokens.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from datetime import timedelta + +from ...._utils import api_path, compact, format_duration, validate_uuid +from ....types import PlainToken, Scope, Token +from .._base import Resource + +__all__ = ["PlatformTokens"] + + +class PlatformTokens(Resource): + """Organization tokens (``ksh_...``) minted by this platform credential. + + A platform credential proves who is calling, never where. To act inside an organization + it does not become organization-scoped: it mints an ordinary organization token, bounded + by its own scopes, and the work is done with that. + """ + + def mint( + self, + organization_id: str, + *, + name: str, + scopes: Sequence[Scope] | None = None, + ttl: timedelta | str | None = None, + ) -> PlainToken: + """Mint an organization token, and return it with its secret. + + The secret, :attr:`~kaitencloud.types.PlainToken.token`, is in this response only. Two + things are easy to get wrong: + + - **Revocation cascades.** Revoking this platform credential revokes every token it + minted. Rotate by minting the new token, adopting it, then revoking the old one -- + never the other way round. + - **The slug is the handle.** :meth:`revoke` takes the token's + :attr:`~kaitencloud.types.PlainToken.slug`, which the server generates. The name you + chose will not do; :meth:`list` maps one to the other. + + Args: + organization_id: The organization to mint the token in. + name: A label, unique among the organization's active tokens. + scopes: The token's scopes, each of which this platform credential must hold; + :data:`~kaitencloud.types.Scope` lists every one an organization token can carry. + ``None`` inherits all of them, which is rarely what a machine consumer needs. + ttl: How long the token lives, as a :class:`~datetime.timedelta` or a Go duration + such as ``"24h"``. Omitted, it lasts until revoked. + + Raises: + PermissionDeniedError: A requested scope is not held by this platform credential. + ConflictError: An active token of the organization already has this name. + """ + body = compact( + { + "name": name, + "scopes": list(scopes) if scopes is not None else None, + "ttl": format_duration(ttl) if ttl is not None else None, + } + ) + return self._api.send("POST", _tokens(organization_id), PlainToken, body=body) + + def list(self, organization_id: str) -> builtins.list[Token]: + """Return the active tokens this platform credential minted in an organization. + + It is the way back from the name you chose to the slug :meth:`revoke` takes. Only this + credential's own tokens are listed, and never their secrets. + """ + return self._api.get_list(_tokens(organization_id), Token) + + def revoke(self, organization_id: str, token_slug: str) -> None: + """Revoke a token this platform credential minted. Immediate and final.""" + self._api.send_empty( + "DELETE", + api_path( + "platform", + "organizations", + validate_uuid(organization_id, "organization_id"), + "tokens", + token_slug, + ), + ) + + +def _tokens(organization_id: str) -> str: + return api_path( + "platform", "organizations", validate_uuid(organization_id, "organization_id"), "tokens" + ) diff --git a/src/kaitencloud/_sync/resources/platform/users.py b/src/kaitencloud/_sync/resources/platform/users.py new file mode 100644 index 0000000..a637e19 --- /dev/null +++ b/src/kaitencloud/_sync/resources/platform/users.py @@ -0,0 +1,28 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/platform/users.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +from ...._utils import api_path, validate_uuid +from .._base import Resource + +__all__ = ["PlatformUsers"] + + +class PlatformUsers(Resource): + """Users, across every organization. + + Users are provisioned when they first sign in, so deletion is the only operation. + """ + + def delete(self, user_id: str) -> None: + """Delete a user, in every organization. + + The user is soft-deleted -- rows it created keep their attribution -- and signing in + again does not bring it back. Requires ``delete:users``. + """ + self._api.send_empty( + "DELETE", api_path("platform", "users", validate_uuid(user_id, "user_id")) + ) diff --git a/src/kaitencloud/_sync/resources/releases.py b/src/kaitencloud/_sync/resources/releases.py new file mode 100644 index 0000000..1026cb5 --- /dev/null +++ b/src/kaitencloud/_sync/resources/releases.py @@ -0,0 +1,61 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/releases.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Sequence + +from ..._utils import api_path, compact +from ...types import Release +from ._base import Resource + +__all__ = ["Releases"] + + +class Releases(Resource): + """Releases: immutable, versioned sets of components. + + A release cannot be edited. Changing one means deleting it and creating another, which + loses the deployment history attached to the first. + """ + + def list(self) -> builtins.list[Release]: + """Return every release, walking all pages.""" + return self._api.list_all("/releases", Release) + + def get(self, release_slug: str) -> Release: + """Return the release identified by ``release_slug``, with its components.""" + return self._api.get(api_path("releases", release_slug), Release) + + def create( + self, + *, + version: str, + description: str | None = None, + component_ids: Sequence[str] | None = None, + slug: str | None = None, + ) -> Release: + """Create a release. + + Args: + version: Its version, e.g. ``"v1.4.0"``, unique in the organization. + description: A description or changelog, up to 500 characters. + component_ids: The ids of the components it is made of, fixed from now on. + slug: Its URL-friendly identifier, generated when omitted. + """ + body = compact( + { + "version": version, + "description": description, + "componentIds": list(component_ids) if component_ids is not None else None, + "slug": slug, + } + ) + return self._api.send("POST", "/releases", Release, body=body) + + def delete(self, release_slug: str) -> None: + """Delete the release identified by ``release_slug``.""" + self._api.send_empty("DELETE", api_path("releases", release_slug)) diff --git a/src/kaitencloud/_sync/resources/service_accounts.py b/src/kaitencloud/_sync/resources/service_accounts.py new file mode 100644 index 0000000..debd610 --- /dev/null +++ b/src/kaitencloud/_sync/resources/service_accounts.py @@ -0,0 +1,93 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/service_accounts.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from datetime import datetime + +from ..._utils import api_path, compact, optional_datetime +from ...types import PlainToken, Scope, ServiceAccount, Token +from ._base import Resource + +__all__ = ["ServiceAccounts"] + + +class ServiceAccounts(Resource): + """Service accounts: machine identities in your organization, and their API tokens.""" + + def list(self) -> builtins.list[ServiceAccount]: + """Return every service account, walking all pages.""" + return self._api.list_all("/service-accounts", ServiceAccount) + + def get(self, service_account_slug: str) -> ServiceAccount: + """Return the service account identified by ``service_account_slug``.""" + return self._api.get(api_path("service-accounts", service_account_slug), ServiceAccount) + + def create(self, *, name: str, slug: str | None = None) -> ServiceAccount: + """Create a service account. + + Args: + name: The account's name, e.g. ``"billing-sync"``. + slug: Its URL-friendly identifier, generated when omitted. Immutable once set. + """ + body = compact({"name": name, "slug": slug}) + return self._api.send("POST", "/service-accounts", ServiceAccount, body=body) + + def update(self, service_account_slug: str, *, name: str) -> None: + """Rename a service account. Its slug cannot be changed.""" + self._api.send_empty( + "PUT", api_path("service-accounts", service_account_slug), body={"name": name} + ) + + def list_tokens(self, service_account_slug: str) -> builtins.list[Token]: + """Return the service account's tokens, walking all pages. Never their secrets.""" + return self._api.list_all( + api_path("service-accounts", service_account_slug, "tokens"), Token + ) + + def create_token( + self, + service_account_slug: str, + *, + name: str, + scopes: Sequence[Scope] | None = None, + slug: str | None = None, + expires_at: datetime | str | None = None, + ) -> PlainToken: + """Create a token for the service account, and return it with its secret. + + The secret, :attr:`~kaitencloud.types.PlainToken.token`, is in this response and + nowhere else: the API keeps only a hash of it. Store it now -- a lost secret can be + replaced, never recovered. + + Args: + service_account_slug: The service account the token authenticates as. + name: A label, unique among the account's active tokens. + scopes: What the token may do, e.g. ``["read:customers", "write:instances"]``; + :data:`~kaitencloud.types.Scope` lists every one the API knows. ``None`` + inherits the service account's scopes. ``write:X`` implies ``read:X``. + slug: Its URL-friendly identifier, generated when omitted. It is what + :meth:`delete_token` takes. + expires_at: When the token stops working. Omitted, it lasts until revoked. + """ + body = { + "name": name, + "scopes": list(scopes) if scopes is not None else None, + **compact({"slug": slug, "expiresAt": optional_datetime(expires_at)}), + } + return self._api.send( + "POST", + api_path("service-accounts", service_account_slug, "tokens"), + PlainToken, + body=body, + ) + + def delete_token(self, service_account_slug: str, token_slug: str) -> None: + """Revoke the token identified by ``token_slug``. The revocation is immediate.""" + self._api.send_empty( + "DELETE", api_path("service-accounts", service_account_slug, "tokens", token_slug) + ) diff --git a/src/kaitencloud/_types.py b/src/kaitencloud/_types.py new file mode 100644 index 0000000..a886703 --- /dev/null +++ b/src/kaitencloud/_types.py @@ -0,0 +1,56 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Type aliases and sentinels shared across the SDK.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from typing import Any, Final, TypeAlias, final + +import httpx + +__all__ = [ + "NOT_GIVEN", + "AsyncCredential", + "AsyncTokenProvider", + "Credential", + "EntitlementValueInput", + "NotGiven", + "TimeoutTypes", + "TokenProvider", +] + +TokenProvider: TypeAlias = Callable[[], str] +"""A callable returning the token to send. + +It is called before every request, so a rotated credential is picked up without rebuilding +the client. +""" + +AsyncTokenProvider: TypeAlias = Callable[[], str | Awaitable[str]] +"""A :data:`TokenProvider` that may also be a coroutine function.""" + +Credential: TypeAlias = str | TokenProvider +AsyncCredential: TypeAlias = str | AsyncTokenProvider + +TimeoutTypes: TypeAlias = float | httpx.Timeout | None +"""Seconds, an :class:`httpx.Timeout`, or ``None`` for no timeout at all.""" + +EntitlementValueInput: TypeAlias = bool | int | float | Mapping[str, Any] +"""A value to grant: ``True``/``False`` for BOOLEAN, a number for NUMBER (``-1`` is unlimited), +a mapping for CONFIG.""" + + +@final +class NotGiven: + """The type of :data:`NOT_GIVEN`, which tells "not passed" apart from an explicit ``None``.""" + + def __bool__(self) -> bool: + return False + + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NOT_GIVEN: Final = NotGiven() diff --git a/src/kaitencloud/_utils.py b/src/kaitencloud/_utils.py new file mode 100644 index 0000000..f568008 --- /dev/null +++ b/src/kaitencloud/_utils.py @@ -0,0 +1,230 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Helpers shared by the sync and async implementations.""" + +from __future__ import annotations + +import asyncio +import email.utils +import inspect +import platform +import random +import time +import uuid +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from typing import Any, Literal +from urllib.parse import quote + +import httpx + +from ._constants import INITIAL_RETRY_DELAY, MAX_RETRY_AFTER, MAX_RETRY_DELAY +from ._exceptions import CredentialError +from ._models import KaitenModel +from ._types import AsyncCredential, Credential, EntitlementValueInput +from ._version import __version__ +from .types import IntegrationParam + +USER_AGENT = ( + f"kaitencloud-python/{__version__} " + f"(python {platform.python_version()}; httpx {httpx.__version__})" +) + +Surface = Literal["core", "platform"] + + +def normalize_base_url(url: str) -> str: + """Return ``url`` as the absolute ``.../api/`` prefix every operation path joins onto. + + Both Kaiten APIs are served under ``/api`` and their contracts publish bare paths below + it, so ``https://kaiten.example.com`` and ``https://kaiten.example.com/api`` must mean the same + thing: without the suffix, every call would answer 404. + """ + candidate = url.strip() + try: + parsed = httpx.URL(candidate) + except httpx.InvalidURL as error: + raise ValueError(f"base_url is not a valid URL: {url!r}") from error + if parsed.scheme not in {"http", "https"} or not parsed.host: + raise ValueError(f"base_url must be an absolute http(s) URL, got {url!r}") + path = parsed.path.rstrip("/") + if not path.endswith("/api"): + path = f"{path}/api" + return f"{parsed.scheme}://{parsed.netloc.decode('ascii')}{path}/" + + +def api_path(*segments: str) -> str: + """Join path segments into an operation path, escaping each one. + + Slugs and names come from callers, not from the API, so they are escaped rather than + trusted -- and an empty one is refused, because it would silently address the collection + instead of a member of it. + """ + escaped = [] + for segment in segments: + if not isinstance(segment, str) or not segment.strip(): + raise ValueError(f"path segments must be non-empty strings, got {segment!r}") + escaped.append(quote(segment, safe="")) + return "/" + "/".join(escaped) + + +def compact(fields: Mapping[str, Any]) -> dict[str, Any]: + """``fields`` without the entries whose value is ``None``. + + An optional field left unset must be absent from a request body, not null: every Kaiten + request body is ``additionalProperties: false`` and several fields refuse an explicit null. + """ + return {key: value for key, value in fields.items() if value is not None} + + +def format_datetime(value: datetime | str) -> str: + """``value`` as an RFC 3339 timestamp. Strings pass through unchanged.""" + if isinstance(value, str): + return value + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError( + f"{value!r} has no timezone: pass an aware datetime, " + "e.g. datetime(2027, 1, 1, tzinfo=timezone.utc)" + ) + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def optional_datetime(value: datetime | str | None) -> str | None: + return None if value is None else format_datetime(value) + + +def format_duration(value: timedelta | str) -> str: + """``value`` as the Go duration the Platform API parses, e.g. ``"900s"``.""" + if isinstance(value, str): + return value + seconds = value.total_seconds() + if seconds <= 0: + raise ValueError(f"a duration must be positive, got {value!r}") + return f"{int(seconds)}s" if seconds.is_integer() else f"{seconds}s" + + +def entitlement_value(value: EntitlementValueInput | KaitenModel) -> dict[str, Any]: + """Wrap a plain value in the typed envelope the API expects for an entitlement value.""" + if isinstance(value, KaitenModel): + return {key: item for key, item in value.to_dict().items() if key != "event_count"} + if isinstance(value, bool): + return {"type": "boolean", "value": value} + if isinstance(value, (int, float)): + return {"type": "number", "value": value} + if isinstance(value, Mapping): + return {"type": "object", "value": dict(value)} + raise TypeError( + "an entitlement value is a bool (BOOLEAN), a number (NUMBER) or a mapping (CONFIG), " + f"got {type(value).__name__}" + ) + + +def integration_body( + *, + external_id: str, + metadata: Mapping[str, Any] | None, + web_url: str | None, + last_error: str | None, +) -> dict[str, Any]: + """The body of an integration: a customer's or an instance's link to an external record.""" + return compact( + { + "external_id": external_id, + "metadata": dict(metadata) if metadata is not None else None, + "web_url": web_url, + "last_error": last_error, + } + ) + + +def integrations_body( + integrations: Mapping[str, IntegrationParam] | None, +) -> dict[str, dict[str, Any]] | None: + if integrations is None: + return None + return {name: dict(integration) for name, integration in integrations.items()} + + +def validate_uuid(value: str, name: str) -> str: + try: + return str(uuid.UUID(str(value))) + except ValueError: + raise ValueError(f"{name} must be a UUID, got {value!r}") from None + + +def check_token(token: object, surface: Surface) -> str: + """Return ``token`` when the API ``surface`` accepts its credential class; raise otherwise. + + Each Kaiten listener refuses the other's credential with a deliberately uninformative + error. Catching the mix-up here, before a request leaves, is the only place a useful + message can come from. + """ + if not isinstance(token, str) or not token.strip(): + raise CredentialError("The token is empty.") + token = token.strip() + if token.startswith("whsec_"): + raise CredentialError( + "whsec_... is a webhook signing secret, not an API credential: " + "pass it to kaitencloud.webhooks.verify_webhook() instead." + ) + if surface == "core" and token.startswith("ksm_"): + raise CredentialError( + "A platform credential (ksm_...) cannot call the Core API, which refuses it. " + "Use KaitenPlatformClient, or mint an organization token with it." + ) + if surface == "platform" and not token.startswith("ksm_"): + raise CredentialError( + "The Platform API only accepts platform credentials (ksm_...). Organization " + "tokens (ksh_...) and identity-provider JWTs belong to KaitenClient." + ) + return token + + +async def async_resolve_token(credential: AsyncCredential) -> object: + if not callable(credential): + return credential + value = credential() + if inspect.isawaitable(value): + return await value + return value + + +def resolve_token(credential: Credential) -> object: + return credential() if callable(credential) else credential + + +def retry_delay(attempt: int, retry_after: str | None = None) -> float: + """Seconds to wait before retrying, after ``attempt`` retries already happened. + + A ``Retry-After`` header wins when the server sent one; otherwise the delay backs off + exponentially with jitter, so clients that failed together do not retry together. + """ + if retry_after: + seconds = _parse_retry_after(retry_after) + if seconds is not None: + return min(max(seconds, 0.0), MAX_RETRY_AFTER) + delay = INITIAL_RETRY_DELAY * 2**attempt * (1 + random.random()) + return float(min(delay, MAX_RETRY_DELAY)) + + +def _parse_retry_after(value: str) -> float | None: + try: + return float(value) + except ValueError: + pass + try: + moment = email.utils.parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return (moment - datetime.now(timezone.utc)).total_seconds() + + +async def async_sleep(seconds: float) -> None: + await asyncio.sleep(seconds) + + +def sleep(seconds: float) -> None: + time.sleep(seconds) diff --git a/src/kaitencloud/_version.py b/src/kaitencloud/_version.py new file mode 100644 index 0000000..33d4406 --- /dev/null +++ b/src/kaitencloud/_version.py @@ -0,0 +1,4 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.1.0" diff --git a/src/kaitencloud/py.typed b/src/kaitencloud/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/kaitencloud/targeting.py b/src/kaitencloud/targeting.py new file mode 100644 index 0000000..8183415 --- /dev/null +++ b/src/kaitencloud/targeting.py @@ -0,0 +1,108 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Builders for feature flag variants, default variants and targeting rules. + +:: + + from kaitencloud import KaitenClient, targeting + + client.feature_flags.create( + name="New checkout", + type="boolean", + variants=[targeting.variant("on", True), targeting.variant("off", False)], + default_variant=targeting.fixed("off"), + targetings=[ + targeting.rule("Growth plan", "__kaiten.license.familySlug == 'growth'", "on"), + targeting.split_rule("Everyone else", "true", {"on": 20, "off": 80}), + ], + ) + +Rules are CEL expressions over the evaluation context, evaluated in order: the first that +matches decides, and a context no rule matches gets the default variant. The server fills the +reserved ``__kaiten`` namespace itself -- ``__kaiten.license.familySlug``, +``__kaiten.instance.metadata`` and so on -- so a rule can target on what a customer bought. +Target a plan by ``familySlug`` rather than ``slug``: the family's slug survives new versions, +while a version's changes every time one is published. + +Splits are sticky: a subject is bucketed on the flag's key and its targeting key, so it keeps +its variant from one evaluation to the next, and one flag's rollout does not decide another's. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from ._utils import format_datetime +from .types import DefaultVariantParam, RolloutStepParam, TargetingParam, VariantParam + +__all__ = [ + "fixed", + "rule", + "schedule", + "schedule_rule", + "split", + "split_rule", + "step", + "variant", +] + + +def variant(name: str, value: Any, *, description: str = "") -> VariantParam: + """One possible value of a flag, e.g. ``variant("on", True)``.""" + return {"name": name, "value": value, "description": description} + + +def fixed(variant: str) -> DefaultVariantParam: + """A default variant that always serves ``variant``.""" + return {"type": "basic", "value": variant} + + +def split(distribution: Mapping[str, int]) -> DefaultVariantParam: + """A default variant that splits traffic by weight, e.g. ``split({"on": 20, "off": 80})``.""" + return {"type": "rollout_percentage", "distribution": _weights(distribution)} + + +def step(variant: str, percentage: int, at: datetime | str) -> RolloutStepParam: + """One end of a scheduled rollout: ``variant`` gets ``percentage`` percent of traffic at ``at``.""" + if not 0 <= percentage <= 100: + raise ValueError(f"percentage must be between 0 and 100, got {percentage}") + return {"variant": variant, "percentage": percentage, "date": format_datetime(at)} + + +def schedule(*, start: RolloutStepParam, end: RolloutStepParam) -> DefaultVariantParam: + """A default variant whose traffic moves linearly from ``start`` to ``end`` over time.""" + return {"type": "rollout_date", "start": start, "end": end} + + +def rule(name: str, expression: str, variant: str) -> TargetingParam: + """A rule serving ``variant`` to every context the CEL ``expression`` matches.""" + return {"type": "basic", "name": name, "rule": expression, "variant": variant} + + +def split_rule(name: str, expression: str, distribution: Mapping[str, int]) -> TargetingParam: + """A rule splitting the contexts ``expression`` matches between variants, by weight.""" + return { + "type": "rollout_percentage", + "name": name, + "rule": expression, + "distribution": _weights(distribution), + } + + +def schedule_rule( + name: str, expression: str, *, start: RolloutStepParam, end: RolloutStepParam +) -> TargetingParam: + """A rule moving the contexts ``expression`` matches from ``start`` to ``end`` over time.""" + return {"type": "rollout_date", "name": name, "rule": expression, "start": start, "end": end} + + +def _weights(distribution: Mapping[str, int]) -> dict[str, int]: + if not distribution: + raise ValueError("a split needs at least one variant") + for name, weight in distribution.items(): + if isinstance(weight, bool) or not isinstance(weight, int) or weight <= 0: + raise ValueError(f"split weights are positive integers, got {name!r}: {weight!r}") + return dict(distribution) diff --git a/src/kaitencloud/types/__init__.py b/src/kaitencloud/types/__init__.py new file mode 100644 index 0000000..c128fc0 --- /dev/null +++ b/src/kaitencloud/types/__init__.py @@ -0,0 +1,197 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/generate_models.py from the snapshots in openapi/. Do not edit by hand: +# change the contract (`task sync:openapi`) or the generator, then run `task generate`. + +"""Models and parameter types for the Kaiten API. + +The models are generated from the API contract. The ``*Param`` typed dictionaries describe +the nested values the client methods accept. +""" + +from ._core import ( + AggregationMethod, + AuditTrail, + BasicTargeting, + BasicVariant, + BooleanEntitlementValue, + Component, + ConfigEntitlementValue, + Connector, + ConnectorActivation, + ConnectorSettings, + ConnectorSettingsSchema, + ConnectorState, + Customer, + CustomerCreationRejected, + CustomerIntegration, + CustomerIntegrationResource, + DefaultVariant, + Deployment, + DeploymentZone, + Entitlement, + EntitlementGroup, + EntitlementGroupSummary, + EntitlementGroupUsage, + EntitlementType, + EntitlementUsage, + EntitlementValue, + ErrorDetail, + FeatureFlag, + FeatureFlagEvaluated, + FeatureFlagType, + Instance, + InstanceEntitlementCapExceeded, + InstanceEntitlementUsagePeriodRolledOver, + InstanceEntitlementUsageReportAccepted, + InstanceEntitlementUsageWarningThresholdReached, + InstanceEntitlementValueGet, + InstanceIntegration, + InstanceIntegrationResource, + InstanceLifecycleStageChanged, + InstanceStatus, + InstanceStatusChanged, + License, + LicenseEntitlement, + LicenseEntitlementType, + LicenseFamilyView, + LicenseLifecycleState, + LicenseType, + ManifestEnvelope, + ManifestFlag, + MetadataField, + MetadataFieldImpact, + MetadataFieldImpactSample, + MetadataFieldReordered, + MetadataResourceType, + NumberEntitlementValue, + PlainToken, + Problem, + Release, + ResetAnchor, + ResetPeriod, + RolloutDateTargeting, + RolloutDateVariant, + RolloutPercentageTargeting, + RolloutPercentageVariant, + RolloutStep, + Scope, + ServiceAccount, + SystemTokenIssuance, + Targeting, + Targetings, + Token, + UsageBehavior, + UsageReportStatus, + User, + Variant, +) +from ._params import ( + BasicTargetingParam, + BasicVariantParam, + DefaultVariantParam, + IntegrationParam, + RolloutDateTargetingParam, + RolloutDateVariantParam, + RolloutPercentageTargetingParam, + RolloutPercentageVariantParam, + RolloutStepParam, + TargetingParam, + VariantParam, +) +from ._platform import ( + Organization, + PlatformCredential, +) + +__all__ = [ + "AggregationMethod", + "AuditTrail", + "BasicTargeting", + "BasicTargetingParam", + "BasicVariant", + "BasicVariantParam", + "BooleanEntitlementValue", + "Component", + "ConfigEntitlementValue", + "Connector", + "ConnectorActivation", + "ConnectorSettings", + "ConnectorSettingsSchema", + "ConnectorState", + "Customer", + "CustomerCreationRejected", + "CustomerIntegration", + "CustomerIntegrationResource", + "DefaultVariant", + "DefaultVariantParam", + "Deployment", + "DeploymentZone", + "Entitlement", + "EntitlementGroup", + "EntitlementGroupSummary", + "EntitlementGroupUsage", + "EntitlementType", + "EntitlementUsage", + "EntitlementValue", + "ErrorDetail", + "FeatureFlag", + "FeatureFlagEvaluated", + "FeatureFlagType", + "Instance", + "InstanceEntitlementCapExceeded", + "InstanceEntitlementUsagePeriodRolledOver", + "InstanceEntitlementUsageReportAccepted", + "InstanceEntitlementUsageWarningThresholdReached", + "InstanceEntitlementValueGet", + "InstanceIntegration", + "InstanceIntegrationResource", + "InstanceLifecycleStageChanged", + "InstanceStatus", + "InstanceStatusChanged", + "IntegrationParam", + "License", + "LicenseEntitlement", + "LicenseEntitlementType", + "LicenseFamilyView", + "LicenseLifecycleState", + "LicenseType", + "ManifestEnvelope", + "ManifestFlag", + "MetadataField", + "MetadataFieldImpact", + "MetadataFieldImpactSample", + "MetadataFieldReordered", + "MetadataResourceType", + "NumberEntitlementValue", + "Organization", + "PlainToken", + "PlatformCredential", + "Problem", + "Release", + "ResetAnchor", + "ResetPeriod", + "RolloutDateTargeting", + "RolloutDateTargetingParam", + "RolloutDateVariant", + "RolloutDateVariantParam", + "RolloutPercentageTargeting", + "RolloutPercentageTargetingParam", + "RolloutPercentageVariant", + "RolloutPercentageVariantParam", + "RolloutStep", + "RolloutStepParam", + "Scope", + "ServiceAccount", + "SystemTokenIssuance", + "Targeting", + "TargetingParam", + "Targetings", + "Token", + "UsageBehavior", + "UsageReportStatus", + "User", + "Variant", + "VariantParam", +] diff --git a/src/kaitencloud/types/_core.py b/src/kaitencloud/types/_core.py new file mode 100644 index 0000000..c09dbd4 --- /dev/null +++ b/src/kaitencloud/types/_core.py @@ -0,0 +1,1582 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/generate_models.py from openapi/openapi.yaml. Do not edit by hand: +# change the contract (`task sync:openapi`) or the generator, then run `task generate`. + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, Any, Literal + +from pydantic import Field + +from .._models import KaitenModel + +__all__ = [ + "AggregationMethod", + "AuditTrail", + "BasicTargeting", + "BasicVariant", + "BooleanEntitlementValue", + "Component", + "ConfigEntitlementValue", + "Connector", + "ConnectorActivation", + "ConnectorSettings", + "ConnectorSettingsSchema", + "ConnectorState", + "Customer", + "CustomerCreationRejected", + "CustomerIntegration", + "CustomerIntegrationResource", + "DefaultVariant", + "Deployment", + "DeploymentZone", + "Entitlement", + "EntitlementGroup", + "EntitlementGroupSummary", + "EntitlementGroupUsage", + "EntitlementType", + "EntitlementUsage", + "EntitlementValue", + "ErrorDetail", + "FeatureFlag", + "FeatureFlagEvaluated", + "FeatureFlagType", + "Instance", + "InstanceEntitlementCapExceeded", + "InstanceEntitlementUsagePeriodRolledOver", + "InstanceEntitlementUsageReportAccepted", + "InstanceEntitlementUsageWarningThresholdReached", + "InstanceEntitlementValueGet", + "InstanceIntegration", + "InstanceIntegrationResource", + "InstanceLifecycleStageChanged", + "InstanceStatus", + "InstanceStatusChanged", + "License", + "LicenseEntitlement", + "LicenseEntitlementType", + "LicenseFamilyView", + "LicenseLifecycleState", + "LicenseType", + "ManifestEnvelope", + "ManifestFlag", + "MetadataField", + "MetadataFieldImpact", + "MetadataFieldImpactSample", + "MetadataFieldReordered", + "MetadataResourceType", + "NumberEntitlementValue", + "PlainToken", + "Problem", + "Release", + "ResetAnchor", + "ResetPeriod", + "RolloutDateTargeting", + "RolloutDateVariant", + "RolloutPercentageTargeting", + "RolloutPercentageVariant", + "RolloutStep", + "Scope", + "ServiceAccount", + "SystemTokenIssuance", + "Targeting", + "Targetings", + "Token", + "UsageBehavior", + "UsageReportStatus", + "User", + "Variant", +] + + +AggregationMethod = Literal["COUNT", "SUM", "AVERAGE", "MIN", "MAX", "LATEST"] +"""How each reported usage value folds into an entitlement's stored total.""" + +EntitlementType = Literal["BOOLEAN", "NUMBER", "CONFIG", "NUMBER_AI_CREDIT"] +"""The value type of an entitlement in the catalog.""" + +FeatureFlagType = Literal["boolean", "string", "number", "object"] +"""The value type of a feature flag's variants.""" + +InstanceStatus = Literal["HEALTHY", "DEGRADED", "INCIDENT", "MAINTENANCE"] +"""The operational status of an instance.""" + +LicenseEntitlementType = Literal["BOOLEAN", "NUMBER", "CONFIG"] +"""The value type of an entitlement granted by a license.""" + +LicenseLifecycleState = Literal["DRAFT", "PUBLISHED", "ARCHIVED"] +"""Whether a license version may be served: ``DRAFT`` before it goes on sale, ``PUBLISHED`` +while it is on sale, ``ARCHIVED`` once it has been withdrawn. +""" + +LicenseType = Literal["DEVELOPMENT", "TRIAL", "PAID", "COMMUNITY"] +"""The kind of a license.""" + +MetadataResourceType = Literal["DEPLOYMENT_ZONE", "INSTANCE"] +"""The resource a metadata field applies to.""" + +ResetAnchor = Literal["CALENDAR", "LICENSE_START"] +"""What a periodic usage window is aligned on.""" + +ResetPeriod = Literal["HOUR", "DAY", "WEEK", "MONTH", "YEAR"] +"""How often a periodic entitlement's usage resets.""" + +Scope = Literal[ + "read:components", + "read:customers", + "read:deployment_zones", + "read:entitlements", + "read:feature_flags", + "read:instances", + "read:licenses", + "read:metadata_fields", + "read:notifications", + "read:organizations", + "read:releases", + "read:tokens", + "read:webhooks", + "write:components", + "write:customers", + "write:deployment_zones", + "write:entitlements", + "write:feature_flags", + "write:instances", + "write:licenses", + "write:metadata_fields", + "write:notifications", + "write:organizations", + "write:releases", + "write:tokens", + "write:webhooks", +] +"""A permission an organization credential can carry, ``read:`` or +``write:``; ``write`` implies ``read`` on the same resource. +""" + +UsageBehavior = Literal["append", "set"] +"""How a usage report applies: ``append`` folds the value in, ``set`` overwrites it.""" + +UsageReportStatus = Literal["ACCEPTED", "REJECTED"] +"""Whether a usage report was persisted.""" + + +class AuditTrail(KaitenModel): + """One entry of an instance's audit trail.""" + + event_name: str = Field(alias="eventName") + """Event name (e.g. ENTITLEMENT_VALUE_GET)""" + + event_type: str = Field(alias="eventType") + """Event type identifier (e.g. com.kaiten.instance.entitlement.v1.value_get)""" + + id: str + """Unique audit trail entry ID""" + + instance_id: str | None = Field(default=None, alias="instanceId") + """Instance UUID (present for instance-scoped events)""" + + instance_slug: str = Field(alias="instanceSlug") + """Instance slug""" + + payload: Any | None = None + """Event payload (JSON)""" + + timestamp: datetime + """When the event occurred""" + + +class BasicTargeting(KaitenModel): + """A targeting rule that serves one variant to every context its CEL rule matches.""" + + name: str + + rule: str + """CEL expression for basicTargeting evaluation""" + + type: Literal["basic"] + + variant: str + + +class BasicVariant(KaitenModel): + """A default variant that always serves one named variant.""" + + type: Literal["basic"] + + value: str + + +class BooleanEntitlementValue(KaitenModel): + """A boolean entitlement value: whether a feature is included.""" + + type: Literal["boolean"] + """Value type discriminator""" + + value: bool + """Boolean value""" + + +class Component(KaitenModel): + """A versioned building block of your product, that releases are made of.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the component was created""" + + created_by: User = Field(alias="createdBy") + """User who created this component""" + + description: str | None = None + """Description of the component""" + + id: str + """Unique identifier for the component""" + + name: str + """Name of the component""" + + previous_component_id: str | None = Field(default=None, alias="previousComponentId") + """Reference to the previous version of this component""" + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + version: str + """Version of the component""" + + +class ConfigEntitlementValue(KaitenModel): + """A configuration entitlement value: a free-form object.""" + + type: Literal["object"] + """Value type discriminator""" + + value: dict[str, Any] + """Object configuration value""" + + +class Connector(KaitenModel): + """A connector registered with this Kaiten deployment.""" + + created_at: datetime + """Timestamp when connector was first registered""" + + entitlement_slug: str | None = None + """Slug of the BOOLEAN entitlement a license must grant for an organization to activate + this connector. Absent means the connector is ungated. + """ + + name: str + """Stable connector identifier""" + + settings_schema: dict[str, Any] + """JSON schema used to validate connector settings payloads""" + + updated_at: datetime + """Timestamp when connector registration was last updated""" + + version: str + """Registered connector version""" + + +class ConnectorActivation(KaitenModel): + """An organization's activation of a connector.""" + + activated_at: datetime = Field(alias="activatedAt") + """When this organization first activated the connector""" + + connector_name: str = Field(alias="connectorName") + """Stable connector identifier""" + + +class ConnectorSettings(KaitenModel): + """An organization's settings for a connector.""" + + connector_name: str + """Stable connector identifier""" + + settings: dict[str, Any] + """Connector settings payload""" + + +class ConnectorSettingsSchema(KaitenModel): + """The JSON schema a connector's settings are validated against.""" + + connector_name: str + """Stable connector identifier""" + + json_schema: dict[str, Any] = Field(alias="schema") + """JSON schema describing supported connector settings payload""" + + version: str + """Registered connector version""" + + +class ConnectorState(KaitenModel): + """Whether a connector is available, licensed and activated for this organization.""" + + activated: bool + """Whether this organization has turned the connector on""" + + activated_at: datetime | None = Field(default=None, alias="activatedAt") + """When this organization activated the connector. Absent when it has not.""" + + available: bool + """Whether this deployment has the connector registered""" + + connector_name: str = Field(alias="connectorName") + """Stable connector identifier""" + + entitled: bool + """Whether this organization's licence includes the connector. True for an ungated connector.""" + + version: str | None = None + """Registered connector version. Absent when the connector is not available.""" + + +class Customer(KaitenModel): + """A customer of your organization: the end client instances are deployed for.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the customer was created""" + + created_by: User = Field(alias="createdBy") + """User who created this customer""" + + domain: str | None = None + """Optional customer domain name""" + + external_customer_id: str | None = Field(default=None, alias="externalCustomerId") + """Optional identifier for the customer in an external system""" + + id: str + """Unique identifier for the customer""" + + integrations: dict[str, CustomerIntegration] | None = None + """Integrations grouped by adapter name""" + + name: str + """Name of the customer""" + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when the customer was last updated""" + + updated_by: User = Field(alias="updatedBy") + """User who last updated this customer""" + + +class CustomerCreationRejected(KaitenModel): + """A customer creation refused because the organization reached its limit.""" + + name: str + + reason: str + + slug: str + + +class CustomerIntegration(KaitenModel): + """A customer's link to a record in a third-party system.""" + + external_id: str + """External identifier in the third-party adapter""" + + last_error: str | None = None + """Last synchronization error message, if any""" + + metadata: dict[str, Any] | None = None + """Adapter-specific integration metadata""" + + synced_at: datetime + """Timestamp of the last synchronization with the adapter""" + + web_url: str | None = None + """Absolute http(s) link to the record in the third-party system""" + + +class CustomerIntegrationResource(KaitenModel): + """A customer as seen through one of its integrations.""" + + domain: str | None = None + """Optional customer domain name""" + + error: str | None = None + """Last synchronization error for this integration, if any""" + + integration_metadata: dict[str, Any] = Field(alias="integrationMetadata") + """Adapter-specific customer integration metadata""" + + name: str + """Customer name""" + + slug: str + """Customer slug""" + + web_url: str | None = Field(default=None, alias="webUrl") + """Absolute http(s) link to the record in the third-party system""" + + +class Deployment(KaitenModel): + """A release deployed to a deployment zone: an append-only history record.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the deployment was recorded""" + + created_by: User = Field(alias="createdBy") + """User who recorded this deployment""" + + deployment_zone_id: str = Field(alias="deploymentZoneId") + """Deployment zone the release was deployed to""" + + id: str + """Unique identifier for this deployment event""" + + release_id: str = Field(alias="releaseId") + """Release that was deployed""" + + +class DeploymentZone(KaitenModel): + """An environment instances run in, and the release currently deployed to it.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the deployment zone was created""" + + created_by: User = Field(alias="createdBy") + """User who created this deployment zone""" + + description: str + """Description of the deployment zone""" + + id: str + """Unique identifier for the deployment zone""" + + metadata: dict[str, Any] | None = None + """Typed metadata for the deployment zone, validated against the org-scoped MetadataField + schema + """ + + name: str + """Name of the deployment zone""" + + release_id: str | None = Field(default=None, alias="releaseId") + """Currently deployed release ID""" + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + type: str + """Environment class of the deployment zone. Free-form: any value is stored as sent, but + production, staging and development are the ones the console labels, and feature-flag + targeting rules match on this field as __kaiten.deploymentZone.type. + """ + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when the deployment zone was last updated""" + + updated_by: User = Field(alias="updatedBy") + """User who last updated the deployment zone""" + + +class Entitlement(KaitenModel): + """A catalog entry: something a license can grant, and how its usage is measured.""" + + aggregation_method: AggregationMethod | str | None = Field( + default=None, alias="aggregationMethod" + ) + """Method used to fold each reported usage value into the stored total (NUMBER only). + Defaults to SUM. + """ + + created_at: datetime = Field(alias="createdAt") + """Timestamp when this entitlement was created""" + + description: str | None + """Description of the entitlement""" + + display_order: int | None = Field(default=None, alias="displayOrder") + """Sort order of this entitlement in customer-facing components (ascending). Defaults to 0.""" + + entitlement_groups: list[EntitlementGroupSummary] | None = Field( + default=None, alias="entitlementGroups" + ) + """Groups associated with this entitlement""" + + group_slugs: list[str] | None = Field(default=None, alias="groupSlugs") + """Groups associated with this entitlement, identified by slug""" + + icon: str | None = None + """Optional icon, stored as a provider-namespaced token (provider:name). Decoupled from any + specific icon library. + """ + + id: str + """Unique identifier for the entitlement""" + + name: str + """Name of the entitlement""" + + reset_anchor: ResetAnchor | str | None = Field(default=None, alias="resetAnchor") + """Phase of the periodic usage window: CALENDAR (UTC calendar-aligned) or LICENSE_START + (phased off the instance's license start date). Required exactly when resetPeriod is + set; defaults to CALENDAR when omitted. Immutable once set. + """ + + reset_period: ResetPeriod | str | None = Field(default=None, alias="resetPeriod") + """Cadence at which usage automatically resets for this NUMBER entitlement. NULL means a + lifetime counter (default). Immutable once set -- a full-replace PUT must echo back the + stored value. + """ + + sale_unit_factor: float | None = Field(default=None, alias="saleUnitFactor") + """Number of base units one sale unit represents (1 sale unit = N base units).""" + + sale_unit_plural: str | None = Field(default=None, alias="saleUnitPlural") + """Plural label of the unit this entitlement is sold in, when it differs from the base unit.""" + + sale_unit_singular: str | None = Field(default=None, alias="saleUnitSingular") + """Singular label of the unit this entitlement is sold in, when it differs from the base + unit. Requires the base unit labels and the full sale unit trio (saleUnitSingular, + saleUnitPlural, saleUnitFactor). + """ + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + type: EntitlementType | str | None = None + """Type of the entitlement""" + + unit_plural: str | None = Field(default=None, alias="unitPlural") + """Plural label of the base unit this entitlement is measured in (NUMBER type only). Must + be provided together with unitSingular. + """ + + unit_singular: str | None = Field(default=None, alias="unitSingular") + """Singular label of the base unit this entitlement is measured in (NUMBER type only). Must + be provided together with unitPlural. + """ + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when this entitlement was last updated""" + + user_facing: bool | None = Field(default=None, alias="userFacing") + """Whether this entitlement is displayed in customer-facing components (e.g. plan and + pricing pages). Defaults to false. + """ + + warning_threshold_percent: int | None = Field(default=None, alias="warningThresholdPercent") + """Percentage of the cap at which an early-warning event fires before the cap is reached. 0 + disables the warning (default). + """ + + +class EntitlementGroup(KaitenModel): + """A named group of entitlements.""" + + description: str | None + """Description of the entitlement group""" + + id: str + """Unique identifier for the entitlement group""" + + name: str + """Name of the entitlement group""" + + slug: str | None = None + """URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + +class EntitlementGroupSummary(KaitenModel): + """A reference to an entitlement group.""" + + id: str + """Unique identifier for the entitlement group""" + + name: str + """Name of the entitlement group""" + + slug: str + """URL-friendly identifier for the entitlement group""" + + +class EntitlementGroupUsage(KaitenModel): + """One entitlement's grant and usage within a group, for one instance.""" + + current_period_end: datetime | None = Field(default=None, alias="currentPeriodEnd") + """End of the current usage window (exclusive). Null for a lifetime entitlement.""" + + current_period_start: datetime | None = Field(default=None, alias="currentPeriodStart") + """Start of the current usage window (inclusive). Null for a lifetime entitlement.""" + + entitlement_id: str = Field(alias="entitlementId") + """Entitlement ID""" + + entitlement_name: str = Field(alias="entitlementName") + """Entitlement name""" + + entitlement_slug: str = Field(alias="entitlementSlug") + """Entitlement slug""" + + entitlement_type: str = Field(alias="entitlementType") + """Entitlement type""" + + license_value: EntitlementValue | None = Field(default=None, alias="licenseValue") + """License limit value, discriminated by the 'type' field. Null when the license grants no + value. + """ + + usage_value: EntitlementValue | None = Field(default=None, alias="usageValue") + """Current usage value, discriminated by the 'type' field. Null when nothing has been + reported yet. + """ + + +class EntitlementUsage(KaitenModel): + """An instance's consumption of one entitlement, next to the grant it is measured against.""" + + current_period_end: datetime | None = Field(default=None, alias="currentPeriodEnd") + """End of the current usage window (exclusive). Null for a lifetime entitlement (no + configured reset period). + """ + + current_period_start: datetime | None = Field(default=None, alias="currentPeriodStart") + """Start of the current usage window (inclusive). Null for a lifetime entitlement (no + configured reset period). + """ + + entitlement_id: str = Field(alias="entitlementId") + """Unique identifier for the entitlement""" + + entitlement_slug: str = Field(alias="entitlementSlug") + """URL-friendly identifier of the entitlement""" + + license_id: str = Field(alias="licenseId") + """Unique identifier for the license to which this entitlement usage belongs""" + + license_slug: str = Field(alias="licenseSlug") + """URL-friendly identifier of the license""" + + limit: EntitlementValue | None = None + """License grant this usage is measured against, discriminated by the 'type' field. Null + when the license grants no value; -1 means unlimited for a NUMBER entitlement. + """ + + value: EntitlementValue + """Resolved entitlement value, discriminated by the 'type' field""" + + +class ErrorDetail(KaitenModel): + """One validation problem inside an API error.""" + + location: str | None = None + """Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'""" + + message: str | None = None + """Error message text""" + + value: Any | None = None + """The value at the given location""" + + +class FeatureFlag(KaitenModel): + """A feature flag: its variants, targeting rules and default variant.""" + + default_variant: DefaultVariant + + description: str | None + + enabled: bool + + event_name: str + + id: str + + metadata: dict[str, Any] + + name: str + + slug: str | None = None + + targetings: Targetings + + type: FeatureFlagType | str + + variants: list[Variant] | None + + +class FeatureFlagEvaluated(KaitenModel): + """A feature flag evaluation, as reported to webhooks.""" + + error_code: str | None = None + """Set when the evaluation failed and the default was returned""" + + error_message: str | None = None + """Human-readable detail accompanying error_code""" + + flag_id: str + """Unique identifier of the evaluated flag""" + + flag_slug: str + """URL-friendly identifier of the evaluated flag""" + + matched_rule_name: str | None = None + """Name of the targeting rule that matched, when the reason is TARGETING_MATCH""" + + reason: str + """Why the value resolved as it did""" + + variant: str | None = None + """Name of the resolved variant, when the flag has one""" + + +class Instance(KaitenModel): + """A deployment of your product for one customer, under one license.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the instance was created""" + + created_by: User = Field(alias="createdBy") + """User who created this instance""" + + customer_id: str = Field(alias="customerId") + """ID of the customer associated with this instance""" + + customer_slug: str = Field(alias="customerSlug") + """Slug of the customer associated with this instance""" + + deployment_zone_id: str | None = Field(default=None, alias="deploymentZoneId") + """ID of the deployment zone associated with this instance (optional)""" + + deployment_zone_slug: str | None = Field(default=None, alias="deploymentZoneSlug") + """Slug of the deployment zone associated with this instance (optional)""" + + description: str + """Brief description of the instance""" + + end_license_date: datetime = Field(alias="endLicenseDate") + """End date of the license for this instance""" + + id: str + """Unique identifier for the instance""" + + integrations: dict[str, InstanceIntegration] | None = None + """Integrations grouped by adapter name""" + + license_id: str = Field(alias="licenseId") + """ID of the license version this instance is pinned to. An ARCHIVED version cannot be + assigned, on create or by changing the license (CreateInstance.LicenseArchived, + UpdateInstance.LicenseArchived); an instance already on a version that is archived later + keeps it. A DRAFT version can be assigned, to try it before publishing. + """ + + license_slug: str = Field(alias="licenseSlug") + """Slug of the license associated with this instance""" + + lifecycle_stage: str | None = Field(default=None, alias="lifecycleStage") + """Commercial lifecycle stage of the instance (free-form; suggested: TRIAL, ACTIVE, + AT_RISK, CHURNED) + """ + + metadata: dict[str, Any] | None = None + """Metadata for the instance""" + + name: str + """Name of the instance""" + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + start_license_date: datetime = Field(alias="startLicenseDate") + """Start date of the license for this instance""" + + status: InstanceStatus | str | None = None + """Operational status of the instance""" + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when the instance was last updated""" + + updated_by: User = Field(alias="updatedBy") + """User who last updated this instance""" + + +class InstanceEntitlementCapExceeded(KaitenModel): + """Usage crossed an entitlement's included value, within its allowance.""" + + entitlement_slug: str + """URL-friendly identifier of the entitlement""" + + overage: float + """Amount by which usage exceeds the cap""" + + threshold: float + """The entitlement cap""" + + value: float + """Current usage value, including overage""" + + +class InstanceEntitlementUsagePeriodRolledOver(KaitenModel): + """A periodic usage window closed and a new one opened.""" + + closed_period_end: datetime | None + + closed_period_start: datetime | None + + entitlement_id: str + + entitlement_slug: str + + event_count: int + + instance_id: str + + is_synthetic: bool + + new_period_start: datetime + + value: float + + +class InstanceEntitlementUsageReportAccepted(KaitenModel): + """The outcome of a usage report.""" + + behavior: UsageBehavior | str + """Report behavior: append folds the value into the stored total, set overwrites it""" + + entitlement_slug: str + """URL-friendly identifier of the entitlement the usage was reported against""" + + event_count: int + """Number of reports folded into the current usage window, including this one""" + + status: UsageReportStatus | str + """Whether the report was persisted""" + + value: float + """Value carried by the report""" + + +class InstanceEntitlementUsageWarningThresholdReached(KaitenModel): + """Usage crossed an entitlement's warning threshold.""" + + boundary: float + """Usage value at which the warning fires (cap * warningThresholdPercent / 100)""" + + entitlement_slug: str + """URL-friendly identifier of the entitlement""" + + threshold: float + """The entitlement cap""" + + value: float + """Current usage value""" + + +class InstanceEntitlementValueGet(KaitenModel): + """An entitlement value read for an instance.""" + + entitlement_id: str + """Unique identifier for the entitlement""" + + entitlement_slug: str + """URL-friendly identifier of the entitlement""" + + instance_id: str + """Unique identifier for the instance the value was read for""" + + license_id: str + """Unique identifier for the license carrying the entitlement""" + + organization_id: str + """Unique identifier for the owning organization""" + + timestamp: datetime + """When the value was read""" + + type: str + """Discriminator of the resolved entitlement value""" + + value: EntitlementValue + """Resolved entitlement value at read time""" + + +class InstanceIntegration(KaitenModel): + """An instance's link to a record in a third-party system.""" + + external_id: str + """External identifier in the third-party adapter""" + + last_error: str | None = None + """Last synchronization error message, if any""" + + metadata: dict[str, Any] | None = None + """Adapter-specific integration metadata""" + + synced_at: datetime + """Timestamp of the last synchronization with the adapter""" + + web_url: str | None = None + """Absolute http(s) link to the record in the third-party system""" + + +class InstanceIntegrationResource(KaitenModel): + """An instance as seen through one of its integrations.""" + + customer_external_id: str | None = Field(default=None, alias="customerExternalId") + """External identifier of the related customer in the same adapter""" + + deployment_zone_id: str | None = Field(default=None, alias="deploymentZoneId") + """Deployment zone ID""" + + description: str + """Instance description""" + + end_license_date: datetime = Field(alias="endLicenseDate") + """License end date""" + + error: str | None = None + """Last synchronization error for this integration, if any""" + + integration_metadata: dict[str, Any] = Field(alias="integrationMetadata") + """Adapter-specific instance integration metadata""" + + license_id: str = Field(alias="licenseId") + """License ID""" + + name: str + """Instance name""" + + slug: str + """Instance slug""" + + start_license_date: datetime = Field(alias="startLicenseDate") + """License start date""" + + web_url: str | None = Field(default=None, alias="webUrl") + """Absolute http(s) link to the record in the third-party system""" + + +class InstanceLifecycleStageChanged(KaitenModel): + """An instance's commercial lifecycle stage changed.""" + + changed_by: str + + instance_id: str + + new_lifecycle_stage: str | None + + previous_lifecycle_stage: str | None + + +class InstanceStatusChanged(KaitenModel): + """An instance's operational status changed.""" + + changed_by: str + + instance_id: str + + new_status: InstanceStatus | str + + previous_status: InstanceStatus | str + + +class License(KaitenModel): + """A version of a license family: one set of entitlement grants, with its lifecycle state.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when this license was created""" + + description: str + """Detailed description of the license capabilities and restrictions""" + + family_id: str | None = Field(default=None, alias="familyId") + """Identifier of the license family this license is a version of. Stable across renames and + new versions: two licenses sharing it are the same product. Always present in responses. + On create, optional: names the family this license is a new version of, as familySlug + does by slug; omit both to create a new family. On update, optional and must be the + license's own family: a version cannot move between families + (UpdateLicense.FamilyNotReassignable). + """ + + family_slug: str | None = Field(default=None, alias="familySlug") + """Write-only on create: slug of the family this license is a new version of. familyId + names the same family by identifier; sent together they must agree. Omit both to create + a new family, of which this license becomes version 1. Never returned -- see familyId. + Refused on update: a version cannot move between families. + """ + + id: str + """Unique identifier for the license""" + + is_default: bool = Field(alias="isDefault") + """Whether this version is the one its family puts forward: at most one version per family, + and only a PUBLISHED one (CreateLicense.DefaultMustBePublished, + UpdateLicense.DefaultMustBePublished). Resolution hands it out ahead of the highest- + numbered published version, so it keeps being served when a newer version is published. + Setting it takes the flag from the family's previous default, which gets a + LICENSE_UPDATED event of its own. On update, sending false unsets it; the default cannot + be archived until another version takes its place or the flag is unset + (ArchiveLicense.DefaultMustBePublished). + """ + + lifecycle_state: LicenseLifecycleState | str | None = Field( + default=None, alias="lifecycleState" + ) + """Whether this version may be served. Always present in responses. Optional on create, + where it is DRAFT or PUBLISHED and PUBLISHED when omitted; ARCHIVED is refused + (CreateLicense.LifecycleStateNotSettable), since a version is archived by withdrawing it + from sale. After that it changes only through publish-license (DRAFT to PUBLISHED), + archive-license (PUBLISHED to ARCHIVED) and unarchive-license (ARCHIVED to PUBLISHED); + update accepts the stored state and refuses any other + (UpdateLicense.LifecycleStateNotSettable). Only a PUBLISHED version can be the family's + default. An ARCHIVED version cannot be assigned to an instance + (CreateInstance.LicenseArchived, UpdateInstance.LicenseArchived); instances already on + it keep it. + """ + + name: str + """Display name of the license""" + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. When omitted, a new version + of an existing family takes {familySlug}-v{version}, and any other license gets one + generated from its name. A license that opens a new family gives the family its slug. + """ + + type: LicenseType | str + """Type of the license determining its capabilities and restrictions""" + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when this license was last updated""" + + version: str + """Version of the license within its family, assigned by the server when the version is + created and never changed afterwards: the version's derived slug + ({familySlug}-v{version}, see slug) is built from it. A number is never reused within a + family, even after its version is deleted. Create refuses any value + (CreateLicense.VersionNotSettable); update accepts only the stored one, so a + representation can be written back as it was read (UpdateLicense.VersionNotSettable). + """ + + version_name: str | None = Field(default=None, alias="versionName") + """Optional human-readable name for the license version. Defaults to "Version - ".""" + + +class LicenseEntitlement(KaitenModel): + """A grant: the value a license gives one entitlement, and how it is enforced.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when this entitlement was created""" + + created_by: User = Field(alias="createdBy") + """User who created this entitlement""" + + entitlement_groups: list[EntitlementGroupSummary] | None = Field( + default=None, alias="entitlementGroups" + ) + """Groups associated with this entitlement""" + + entitlement_name: str = Field(alias="entitlementName") + """Name of the entitlement feature or service""" + + entitlement_slug: str | None = Field(default=None, alias="entitlementSlug") + """URL-friendly identifier for the entitlement""" + + entitlement_type: LicenseEntitlementType | str | None = Field( + default=None, alias="entitlementType" + ) + """Type of entitlement (BOOLEAN, NUMBER, or CONFIG)""" + + license_id: str = Field(alias="licenseId") + """Unique identifier for the license this entitlement belongs to""" + + license_slug: str = Field(alias="licenseSlug") + """URL-friendly identifier for the license this entitlement belongs to""" + + limit_cap_exceeded_overage_percent: int | None = Field( + default=None, alias="limitCapExceededOveragePercent" + ) + """Derives this grant's enforcement from its own numeric value: -1 when the value is + unlimited (no cap, no overage); otherwise 0 rejects usage above the value (hard limit) + and a positive percentage allows usage to exceed it by that much before being rejected + (soft limit). Null for non-numeric (BOOLEAN/CONFIG) grants. + """ + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when this entitlement was last updated""" + + updated_by: User = Field(alias="updatedBy") + """User who last updated this entitlement""" + + value: EntitlementValue + """Typed entitlement value. Supported shapes: {type:number,value:number}, + {type:boolean,value:boolean}, {type:object,value:{...}} + """ + + +class LicenseFamilyView(KaitenModel): + """A license family -- the product -- resolved to one of its versions.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when this family was created""" + + current_version: License | None = Field(default=None, alias="currentVersion") + """The version this read is about. By default the version the family resolves to: its + default version if it has one, otherwise its highest-numbered PUBLISHED version. Under + ?version=N it is the version asked for instead, whatever its lifecycle state. Absent + when the family has no PUBLISHED version, which only a list or an include=versions read + returns. + """ + + id: str + """Unique identifier for the license family""" + + slug: str + """URL-friendly identifier of the family, unique per organization and stable across renames + and new versions. Deleting the family's last version deletes the family and frees the + slug. + """ + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when a version was last added to this family, or when the family was created. + Renames, lifecycle moves and default changes update the versions' own updatedAt, not + this one. + """ + + version_count: int = Field(alias="versionCount") + """Number of versions in this family, whatever their lifecycle state""" + + versions: list[License] | None = None + """Every version of the family, oldest first. Only returned when explicitly requested with + include=versions, and then never null: a family always has at least one version. + """ + + +class ManifestEnvelope(KaitenModel): + flags: list[ManifestFlag] | None + + +class ManifestFlag(KaitenModel): + """One flag of the OpenFeature manifest.""" + + default_value: Any = Field(alias="defaultValue") + + description: str | None = None + + key: str + + name: str | None = None + + type: str + + +class MetadataField(KaitenModel): + """A typed field an organization declares on its instances or deployment zones.""" + + archived_at: datetime | None = Field(default=None, alias="archivedAt") + """Timestamp when the field was archived (soft delete). Absent if active.""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the field was created""" + + created_by: User = Field(alias="createdBy") + """User who created this field""" + + display_order: int | None = Field(default=None, alias="displayOrder") + """Sort order in the admin UI; ties are broken by createdAt ASC. Settable only through POST + /metadata-fields/reorder; never echoed back on update. + """ + + id: str + """Unique identifier""" + + json_schema: dict[str, Any] = Field(alias="jsonSchema") + """JSON Schema 2020-12 document describing the value space. Example: + {"type":"string","enum":["a","b"]} + """ + + key: str + """Stable identifier, unique per (organization, resource type) on non-archived rows. + Immutable after creation; update must echo back the stored value. + """ + + label: str + """Human-readable label shown in the admin UI""" + + resource_type: MetadataResourceType | str = Field(alias="resourceType") + """Resource type this field applies to. Immutable after creation; update must echo back the + stored value. + """ + + updated_at: datetime = Field(alias="updatedAt") + """Timestamp when the field was last updated""" + + updated_by: User = Field(alias="updatedBy") + """User who last updated this field""" + + +class MetadataFieldImpact(KaitenModel): + """What a candidate metadata field schema would invalidate.""" + + count: int + """Number of resources whose stored value for this field would no longer satisfy the + candidate schema + """ + + samples: list[MetadataFieldImpactSample] | None + """Up to 5 impacted resources, for display""" + + +class MetadataFieldImpactSample(KaitenModel): + """A resource a metadata field schema change would invalidate.""" + + name: str + """Resource display name""" + + slug: str + """Resource slug""" + + +class MetadataFieldReordered(KaitenModel): + """Metadata fields were given a new display order.""" + + ids: list[str] | None + """Metadata field ids in their new display order""" + + +class NumberEntitlementValue(KaitenModel): + """A numeric entitlement value: a quota, or an amount consumed.""" + + event_count: int | None = None + """Number of events accumulated (server-computed)""" + + type: Literal["number"] + """Value type discriminator""" + + value: float + """Numeric value""" + + +class PlainToken(KaitenModel): + """A token together with its plaintext secret, which the API returns exactly once.""" + + created_at: datetime = Field(alias="createdAt") + + created_by: User = Field(alias="createdBy") + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + + id: str | None = None + + name: str + + revoked_at: datetime | None = Field(default=None, alias="revokedAt") + + revoked_by: User | None = Field(default=None, alias="revokedBy") + + scopes: list[str] | None + + service_account_id: str | None = Field(default=None, alias="serviceAccountId") + + slug: str | None = None + """Optional URL-friendly identifier, unique per service account. Auto-generated if not + provided. + """ + + token: str | None = None + + +class Problem(KaitenModel): + """An RFC 9457 problem document, which every API error carries.""" + + code: str | None = None + """Stable, machine-readable error code.""" + + detail: str | None = None + """A human-readable explanation specific to this occurrence of the problem.""" + + error_id: str | None = Field(default=None, alias="errorId") + """Correlation id for the server-side log entry describing the withheld cause.""" + + errors: list[ErrorDetail] | None = None + """Optional list of individual error details""" + + instance: str | None = None + """A URI reference that identifies the specific occurrence of the problem.""" + + status: int | None = None + """HTTP status code""" + + title: str | None = None + """A short, human-readable summary of the problem type. This value should not change + between occurrences of the error. + """ + + type: str | None = None + """A URI reference to human-readable documentation for the error.""" + + +class Release(KaitenModel): + """An immutable, versioned set of components.""" + + component_ids: list[str] | None = Field(default=None, alias="componentIds") + """List of component IDs to link to this release""" + + components: list[Component] | None = None + """List of components included in this release, fixed at creation""" + + created_at: datetime = Field(alias="createdAt") + """Timestamp when the release was created""" + + created_by: User = Field(alias="createdBy") + """User who created this release""" + + description: str | None = None + """Description of the release""" + + id: str + """Unique identifier for the release""" + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + version: str + """Version of the release""" + + +class RolloutDateTargeting(KaitenModel): + """A targeting rule that shifts traffic between variants over a date range.""" + + end: RolloutStep + + name: str + + rule: str + """CEL expression for basicTargeting evaluation""" + + start: RolloutStep + + type: Literal["rollout_date"] + + +class RolloutDateVariant(KaitenModel): + """A default variant that shifts traffic between variants over a date range.""" + + end: RolloutStep + + start: RolloutStep + + type: Literal["rollout_date"] + + +class RolloutPercentageTargeting(KaitenModel): + """A targeting rule that splits the contexts it matches between variants.""" + + distribution: dict[str, int] + + name: str + + rule: str + """CEL expression for basicTargeting evaluation""" + + type: Literal["rollout_percentage"] + + +class RolloutPercentageVariant(KaitenModel): + """A default variant that splits traffic between variants by weight.""" + + distribution: dict[str, int] + + type: Literal["rollout_percentage"] + + +class RolloutStep(KaitenModel): + """One end of a date-based rollout.""" + + date: datetime + + percentage: int + + variant: str + + +class ServiceAccount(KaitenModel): + """A machine identity inside an organization, owning API tokens.""" + + created_at: datetime | None = Field(default=None, alias="createdAt") + + created_by: User | None = Field(default=None, alias="createdBy") + + external_id: str | None = Field(default=None, alias="externalId") + + id: str | None = None + + name: str + + slug: str | None = None + """Optional URL-friendly identifier, unique per organization. Auto-generated if not provided.""" + + tokens: list[Token] | None + + +class SystemTokenIssuance(KaitenModel): + """A platform credential minted a token inside this organization.""" + + credential_kind: str = Field(alias="credentialKind") + """Credential class that performed the mint; "platform" is the system:kaiten identity""" + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + """Absent when the minted credential does not expire""" + + issued_at: datetime = Field(alias="issuedAt") + """When the credential was minted""" + + issued_token_id: str = Field(alias="issuedTokenId") + """The credential that was minted""" + + name: str + """Human-readable label the caller gave the minted credential""" + + platform_token_id: str = Field(alias="platformTokenId") + """Platform credential that performed the mint; revoking it revokes everything it issued""" + + scopes: list[str] | None + """Scopes granted to the minted credential""" + + slug: str + """Server-assigned identifier of the minted credential""" + + +class Token(KaitenModel): + """A token's metadata. Never its secret.""" + + created_at: datetime = Field(alias="createdAt") + + created_by: User = Field(alias="createdBy") + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + + id: str | None = None + + name: str + + revoked_at: datetime | None = Field(default=None, alias="revokedAt") + + revoked_by: User | None = Field(default=None, alias="revokedBy") + + scopes: list[str] | None + + service_account_id: str | None = Field(default=None, alias="serviceAccountId") + + slug: str | None = None + """Optional URL-friendly identifier, unique per service account. Auto-generated if not + provided. + """ + + +class User(KaitenModel): + """A reference to the user behind an action.""" + + id: str + """ID of the user""" + + name: str | None = None + """Name of the user""" + + +class Variant(KaitenModel): + """One possible value of a feature flag.""" + + description: str + + name: str + + value: Any + + +DefaultVariant = Annotated[ + BasicVariant | RolloutDateVariant | RolloutPercentageVariant, Field(discriminator="type") +] +"""What a feature flag serves when no targeting rule matches, discriminated by ``type``.""" + +EntitlementValue = Annotated[ + NumberEntitlementValue | BooleanEntitlementValue | ConfigEntitlementValue, + Field(discriminator="type"), +] +"""A typed entitlement value, discriminated by ``type``: a number, a boolean or a config object.""" + +Targeting = Annotated[ + BasicTargeting | RolloutDateTargeting | RolloutPercentageTargeting, Field(discriminator="type") +] +"""One targeting rule of a feature flag, discriminated by ``type``.""" + +Targetings = list[Targeting] | None +"""A feature flag's targeting rules, evaluated in order; the first match wins.""" + + +for _model in ( + AuditTrail, + BasicTargeting, + BasicVariant, + BooleanEntitlementValue, + Component, + ConfigEntitlementValue, + Connector, + ConnectorActivation, + ConnectorSettings, + ConnectorSettingsSchema, + ConnectorState, + Customer, + CustomerCreationRejected, + CustomerIntegration, + CustomerIntegrationResource, + Deployment, + DeploymentZone, + Entitlement, + EntitlementGroup, + EntitlementGroupSummary, + EntitlementGroupUsage, + EntitlementUsage, + ErrorDetail, + FeatureFlag, + FeatureFlagEvaluated, + Instance, + InstanceEntitlementCapExceeded, + InstanceEntitlementUsagePeriodRolledOver, + InstanceEntitlementUsageReportAccepted, + InstanceEntitlementUsageWarningThresholdReached, + InstanceEntitlementValueGet, + InstanceIntegration, + InstanceIntegrationResource, + InstanceLifecycleStageChanged, + InstanceStatusChanged, + License, + LicenseEntitlement, + LicenseFamilyView, + ManifestEnvelope, + ManifestFlag, + MetadataField, + MetadataFieldImpact, + MetadataFieldImpactSample, + MetadataFieldReordered, + NumberEntitlementValue, + PlainToken, + Problem, + Release, + RolloutDateTargeting, + RolloutDateVariant, + RolloutPercentageTargeting, + RolloutPercentageVariant, + RolloutStep, + ServiceAccount, + SystemTokenIssuance, + Token, + User, + Variant, +): + _model.model_rebuild() +del _model diff --git a/src/kaitencloud/types/_params.py b/src/kaitencloud/types/_params.py new file mode 100644 index 0000000..e083959 --- /dev/null +++ b/src/kaitencloud/types/_params.py @@ -0,0 +1,126 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The shapes methods accept for nested request values, as typed dictionaries. + +A plain ``dict`` literal satisfies them, and a type checker verifies its keys:: + + client.customers.create( + name="Acme", + integrations={"attio": {"external_id": "rec_123"}}, + ) + +The builders in :mod:`kaitencloud.targeting` produce the feature flag ones. +""" + +from __future__ import annotations + +from typing import Any, Literal, TypeAlias + +from typing_extensions import Required, TypedDict + +__all__ = [ + "BasicTargetingParam", + "BasicVariantParam", + "DefaultVariantParam", + "IntegrationParam", + "RolloutDateTargetingParam", + "RolloutDateVariantParam", + "RolloutPercentageTargetingParam", + "RolloutPercentageVariantParam", + "RolloutStepParam", + "TargetingParam", + "VariantParam", +] + + +class IntegrationParam(TypedDict, total=False): + """A link from a customer or an instance to a record in a third-party system.""" + + external_id: Required[str] + """The record's identifier in the third-party system.""" + + metadata: dict[str, Any] + """Adapter-specific metadata.""" + + web_url: str + """An absolute http(s) link to the record.""" + + last_error: str + """The last synchronization error, if any.""" + + +class VariantParam(TypedDict, total=False): + """One possible value of a feature flag.""" + + name: Required[str] + value: Required[Any] + description: str + + +class RolloutStepParam(TypedDict): + """One end of a date-based rollout: a variant's share of traffic at a moment.""" + + variant: str + percentage: int + date: str + + +class BasicVariantParam(TypedDict): + """A default variant that always serves one variant.""" + + type: Literal["basic"] + value: str + + +class RolloutPercentageVariantParam(TypedDict): + """A default variant that splits traffic between variants by weight.""" + + type: Literal["rollout_percentage"] + distribution: dict[str, int] + + +class RolloutDateVariantParam(TypedDict): + """A default variant that shifts traffic between variants over a date range.""" + + type: Literal["rollout_date"] + start: RolloutStepParam + end: RolloutStepParam + + +DefaultVariantParam: TypeAlias = ( + BasicVariantParam | RolloutPercentageVariantParam | RolloutDateVariantParam +) + + +class BasicTargetingParam(TypedDict): + """A rule serving one variant to every context its CEL expression matches.""" + + type: Literal["basic"] + name: str + rule: str + variant: str + + +class RolloutPercentageTargetingParam(TypedDict): + """A rule splitting the contexts it matches between variants by weight.""" + + type: Literal["rollout_percentage"] + name: str + rule: str + distribution: dict[str, int] + + +class RolloutDateTargetingParam(TypedDict): + """A rule shifting the contexts it matches between variants over a date range.""" + + type: Literal["rollout_date"] + name: str + rule: str + start: RolloutStepParam + end: RolloutStepParam + + +TargetingParam: TypeAlias = ( + BasicTargetingParam | RolloutPercentageTargetingParam | RolloutDateTargetingParam +) diff --git a/src/kaitencloud/types/_platform.py b/src/kaitencloud/types/_platform.py new file mode 100644 index 0000000..d2bbe99 --- /dev/null +++ b/src/kaitencloud/types/_platform.py @@ -0,0 +1,62 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/generate_models.py from openapi/platform-openapi.yaml. Do not edit by hand: +# change the contract (`task sync:openapi`) or the generator, then run `task generate`. + +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field + +from .._models import KaitenModel + +__all__ = [ + "Organization", + "PlatformCredential", +] + + +class Organization(KaitenModel): + """A Kaiten organization: a tenant.""" + + external_id: str + """External identifier for the organization, used for integration with external systems""" + + id: str + """Unique identifier for the organization""" + + name: str + """Display name of the organization""" + + +class PlatformCredential(KaitenModel): + """The platform credential (``ksm_...``) a platform client authenticates with.""" + + created_at: datetime = Field(alias="createdAt") + + credential_kind: str = Field(alias="credentialKind") + """Credential class of the calling token""" + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + """Absent when the credential does not expire""" + + id: str + + name: str + + scopes: list[str] | None + + slug: str + + subject: str + """External id of the platform identity this credential authenticates""" + + +for _model in ( + Organization, + PlatformCredential, +): + _model.model_rebuild() +del _model diff --git a/src/kaitencloud/types/_webhooks.py b/src/kaitencloud/types/_webhooks.py new file mode 100644 index 0000000..0cc73bc --- /dev/null +++ b/src/kaitencloud/types/_webhooks.py @@ -0,0 +1,1043 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/generate_models.py from openapi/openapi.yaml. Do not edit by hand: +# change the contract (`task sync:openapi`) or the generator, then run `task generate`. + +"""The events Kaiten delivers to webhook endpoints, one model per published contract.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field + +from .._models import KaitenModel +from ._core import ( + Component, + Customer, + CustomerCreationRejected, + Deployment, + DeploymentZone, + Entitlement, + EntitlementGroup, + EntitlementUsage, + FeatureFlag, + FeatureFlagEvaluated, + Instance, + InstanceEntitlementCapExceeded, + InstanceEntitlementUsagePeriodRolledOver, + InstanceEntitlementUsageReportAccepted, + InstanceEntitlementUsageWarningThresholdReached, + InstanceEntitlementValueGet, + InstanceLifecycleStageChanged, + InstanceStatusChanged, + License, + LicenseEntitlement, + LicenseFamilyView, + MetadataField, + MetadataFieldReordered, + Release, + SystemTokenIssuance, +) + +__all__ = [ + "WEBHOOK_EVENTS", + "ComponentCreatedEvent", + "ComponentDeletedEvent", + "ComponentUpdatedEvent", + "CustomerCreatedEvent", + "CustomerCreationRejectedEvent", + "CustomerDeletedEvent", + "CustomerUpdatedEvent", + "DeploymentZoneCreatedEvent", + "DeploymentZoneDeletedEvent", + "DeploymentZoneUpdatedEvent", + "EntitlementCreatedEvent", + "EntitlementDeletedEvent", + "EntitlementGroupCreatedEvent", + "EntitlementGroupDeletedEvent", + "EntitlementGroupUpdatedEvent", + "EntitlementUpdatedEvent", + "FeatureFlagCreatedEvent", + "FeatureFlagDeletedEvent", + "FeatureFlagEvaluatedEvent", + "FeatureFlagUpdatedEvent", + "InstanceCreatedEvent", + "InstanceDeletedEvent", + "InstanceDeployedEvent", + "InstanceEntitlementCapExceededEvent", + "InstanceEntitlementUsagePeriodRolledOverEvent", + "InstanceEntitlementUsageReachedEvent", + "InstanceEntitlementUsageReportAcceptedEvent", + "InstanceEntitlementUsageReportRejectedEvent", + "InstanceEntitlementUsageWarningThresholdReachedEvent", + "InstanceEntitlementValueGetEvent", + "InstanceLifecycleStageChangedEvent", + "InstanceMigratedEvent", + "InstanceStatusChangedEvent", + "InstanceUpdatedEvent", + "LicenseArchivedEvent", + "LicenseCreatedEvent", + "LicenseDeletedEvent", + "LicenseEntitlementAssignedEvent", + "LicenseEntitlementUnassignedEvent", + "LicenseEntitlementUpdatedEvent", + "LicenseFamilyCreatedEvent", + "LicenseFamilyDeletedEvent", + "LicenseFamilyUpdatedEvent", + "LicensePublishedEvent", + "LicenseUnarchivedEvent", + "LicenseUpdatedEvent", + "MetadataFieldArchivedEvent", + "MetadataFieldCreatedEvent", + "MetadataFieldReorderedEvent", + "MetadataFieldUnarchivedEvent", + "MetadataFieldUpdatedEvent", + "ReleaseCreatedEvent", + "ReleaseDeletedEvent", + "ReleaseDeployedEvent", + "SystemOrganizationTokenIssuedEvent", + "WebhookEvent", +] + + +class ComponentCreatedEvent(KaitenModel): + """Triggered when a new component is created. Delivered as + ``com.kaiten.component.v1.created``. + """ + + name: Literal["COMPONENT_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.component.v1.created"] + """The event type: what tells one event from another.""" + + data: Component + """What the event is about.""" + + +class ComponentDeletedEvent(KaitenModel): + """Triggered when a component is deleted. Delivered as ``com.kaiten.component.v1.deleted``.""" + + name: Literal["COMPONENT_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.component.v1.deleted"] + """The event type: what tells one event from another.""" + + data: Component + """What the event is about.""" + + +class ComponentUpdatedEvent(KaitenModel): + """Triggered when a component is updated or auto-versioned. Delivered as + ``com.kaiten.component.v1.updated``. + """ + + name: Literal["COMPONENT_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.component.v1.updated"] + """The event type: what tells one event from another.""" + + data: Component + """What the event is about.""" + + +class CustomerCreatedEvent(KaitenModel): + """Triggered when a new customer is created. Delivered as ``com.kaiten.customer.v1.created``.""" + + name: Literal["CUSTOMER_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.customer.v1.created"] + """The event type: what tells one event from another.""" + + data: Customer + """What the event is about.""" + + +class CustomerCreationRejectedEvent(KaitenModel): + """Triggered when a customer creation is refused because the organization has reached its + customer entitlement cap. No customer is created. Delivered as + ``com.kaiten.customer.v1.creation_rejected``. + """ + + name: Literal["CUSTOMER_CREATION_REJECTED"] + """The event name.""" + + type: Literal["com.kaiten.customer.v1.creation_rejected"] + """The event type: what tells one event from another.""" + + data: CustomerCreationRejected + """What the event is about.""" + + +class CustomerDeletedEvent(KaitenModel): + """Triggered when a customer is deleted. Delivered as ``com.kaiten.customer.v1.deleted``.""" + + name: Literal["CUSTOMER_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.customer.v1.deleted"] + """The event type: what tells one event from another.""" + + data: Customer + """What the event is about.""" + + +class CustomerUpdatedEvent(KaitenModel): + """Triggered when a customer is updated. Delivered as ``com.kaiten.customer.v1.updated``.""" + + name: Literal["CUSTOMER_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.customer.v1.updated"] + """The event type: what tells one event from another.""" + + data: Customer + """What the event is about.""" + + +class DeploymentZoneCreatedEvent(KaitenModel): + """Triggered when a new deployment zone is created. Delivered as + ``com.kaiten.deployment_zone.v1.created``. + """ + + name: Literal["DEPLOYMENT_ZONE_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.deployment_zone.v1.created"] + """The event type: what tells one event from another.""" + + data: DeploymentZone + """What the event is about.""" + + +class DeploymentZoneDeletedEvent(KaitenModel): + """Triggered when a deployment zone is deleted. Delivered as + ``com.kaiten.deployment_zone.v1.deleted``. + """ + + name: Literal["DEPLOYMENT_ZONE_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.deployment_zone.v1.deleted"] + """The event type: what tells one event from another.""" + + data: DeploymentZone + """What the event is about.""" + + +class ReleaseDeployedEvent(KaitenModel): + """Triggered when a release is deployed to a deployment zone, either at zone creation or + when an existing zone's release changes. Delivered as + ``com.kaiten.deployment_zone.v1.release_deployed``. + """ + + name: Literal["RELEASE_DEPLOYED"] + """The event name.""" + + type: Literal["com.kaiten.deployment_zone.v1.release_deployed"] + """The event type: what tells one event from another.""" + + data: Deployment + """What the event is about.""" + + +class DeploymentZoneUpdatedEvent(KaitenModel): + """Triggered when a deployment zone is updated. Delivered as + ``com.kaiten.deployment_zone.v1.updated``. + """ + + name: Literal["DEPLOYMENT_ZONE_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.deployment_zone.v1.updated"] + """The event type: what tells one event from another.""" + + data: DeploymentZone + """What the event is about.""" + + +class EntitlementCreatedEvent(KaitenModel): + """Triggered when a new entitlement is created. Delivered as + ``com.kaiten.entitlement.v1.created``. + """ + + name: Literal["ENTITLEMENT_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.entitlement.v1.created"] + """The event type: what tells one event from another.""" + + data: Entitlement + """What the event is about.""" + + +class EntitlementDeletedEvent(KaitenModel): + """Triggered when an entitlement is deleted. Delivered as + ``com.kaiten.entitlement.v1.deleted``. + """ + + name: Literal["ENTITLEMENT_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.entitlement.v1.deleted"] + """The event type: what tells one event from another.""" + + data: Entitlement + """What the event is about.""" + + +class EntitlementUpdatedEvent(KaitenModel): + """Triggered when an entitlement is updated. Delivered as + ``com.kaiten.entitlement.v1.updated``. + """ + + name: Literal["ENTITLEMENT_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.entitlement.v1.updated"] + """The event type: what tells one event from another.""" + + data: Entitlement + """What the event is about.""" + + +class EntitlementGroupCreatedEvent(KaitenModel): + """Triggered when a new entitlement group is created. Delivered as + ``com.kaiten.entitlement_group.v1.created``. + """ + + name: Literal["ENTITLEMENT_GROUP_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.entitlement_group.v1.created"] + """The event type: what tells one event from another.""" + + data: EntitlementGroup + """What the event is about.""" + + +class EntitlementGroupDeletedEvent(KaitenModel): + """Triggered when an entitlement group is deleted. Delivered as + ``com.kaiten.entitlement_group.v1.deleted``. + """ + + name: Literal["ENTITLEMENT_GROUP_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.entitlement_group.v1.deleted"] + """The event type: what tells one event from another.""" + + data: EntitlementGroup + """What the event is about.""" + + +class EntitlementGroupUpdatedEvent(KaitenModel): + """Triggered when an entitlement group is updated. Delivered as + ``com.kaiten.entitlement_group.v1.updated``. + """ + + name: Literal["ENTITLEMENT_GROUP_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.entitlement_group.v1.updated"] + """The event type: what tells one event from another.""" + + data: EntitlementGroup + """What the event is about.""" + + +class FeatureFlagCreatedEvent(KaitenModel): + """Triggered when a new feature flag is created. Delivered as + ``com.kaiten.feature_flag.v1.created``. + """ + + name: Literal["FEATURE_FLAG_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.feature_flag.v1.created"] + """The event type: what tells one event from another.""" + + data: FeatureFlag + """What the event is about.""" + + +class FeatureFlagDeletedEvent(KaitenModel): + """Triggered when a feature flag is deleted. Delivered as + ``com.kaiten.feature_flag.v1.deleted``. + """ + + name: Literal["FEATURE_FLAG_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.feature_flag.v1.deleted"] + """The event type: what tells one event from another.""" + + data: FeatureFlag + """What the event is about.""" + + +class FeatureFlagEvaluatedEvent(KaitenModel): + """Triggered on every flag evaluation. The payload carries no evaluation context, so no + targeting key or user attribute is exposed. Delivered as + ``com.kaiten.feature_flag.v1.evaluated``. + """ + + name: Literal["FEATURE_FLAG_EVALUATED"] + """The event name.""" + + type: Literal["com.kaiten.feature_flag.v1.evaluated"] + """The event type: what tells one event from another.""" + + data: FeatureFlagEvaluated + """What the event is about.""" + + +class FeatureFlagUpdatedEvent(KaitenModel): + """Triggered when a feature flag is updated. Delivered as + ``com.kaiten.feature_flag.v1.updated``. + """ + + name: Literal["FEATURE_FLAG_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.feature_flag.v1.updated"] + """The event type: what tells one event from another.""" + + data: FeatureFlag + """What the event is about.""" + + +class SystemOrganizationTokenIssuedEvent(KaitenModel): + """Triggered when a platform credential mints an organization-scoped credential for the + `system:kaiten` platform identity inside this organization. Carries the platform + credential that performed the mint and the scopes granted, never the credential value. + Expect this at the rate an integration refreshes: roughly 120 per day per process at a + 15-minute lifetime. Delivered as ``com.kaiten.identity.v1.system_token_issued``. + """ + + name: Literal["SYSTEM_ORGANIZATION_TOKEN_ISSUED"] + """The event name.""" + + type: Literal["com.kaiten.identity.v1.system_token_issued"] + """The event type: what tells one event from another.""" + + data: SystemTokenIssuance + """What the event is about.""" + + +class InstanceEntitlementCapExceededEvent(KaitenModel): + """Triggered when a SOFT-enforcement entitlement's usage crosses from below the cap to + above it. Fires once per crossing. Delivered as + ``com.kaiten.instance.entitlement.v1.cap_exceeded``. + """ + + name: Literal["INSTANCE_ENTITLEMENT_CAP_EXCEEDED"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.cap_exceeded"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementCapExceeded + """What the event is about.""" + + +class InstanceEntitlementUsagePeriodRolledOverEvent(KaitenModel): + """Triggered once per usage window closed by a report: the entitlement's own stored bucket, + and one synthetic event per window skipped entirely since the last report. Delivered as + ``com.kaiten.instance.entitlement.v1.usage_period_rolled_over``. + """ + + name: Literal["INSTANCE_ENTITLEMENT_USAGE_PERIOD_ROLLED_OVER"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.usage_period_rolled_over"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementUsagePeriodRolledOver + """What the event is about.""" + + +class InstanceEntitlementUsageReachedEvent(KaitenModel): + """Triggered when an instance's entitlement usage reaches its threshold. Delivered as + ``com.kaiten.instance.entitlement.v1.usage_reached``. + """ + + name: Literal["INSTANCE_ENTITLEMENT_USAGE_REACHED"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.usage_reached"] + """The event type: what tells one event from another.""" + + data: EntitlementUsage + """What the event is about.""" + + +class InstanceEntitlementUsageReportAcceptedEvent(KaitenModel): + """Triggered when a usage report is persisted, including a SOFT-enforcement overage. + Delivered as ``com.kaiten.instance.entitlement.v1.usage_report_accepted``. + """ + + name: Literal["ENTITLEMENT_USAGE_REPORT_ACCEPTED"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.usage_report_accepted"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementUsageReportAccepted + """What the event is about.""" + + +class InstanceEntitlementUsageReportRejectedEvent(KaitenModel): + """Triggered when a usage report is refused because it would take a HARD-enforcement + entitlement past its cap. The usage is not persisted. Delivered as + ``com.kaiten.instance.entitlement.v1.usage_report_rejected``. + """ + + name: Literal["ENTITLEMENT_USAGE_REPORT_REJECTED"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.usage_report_rejected"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementUsageReportAccepted + """What the event is about.""" + + +class InstanceEntitlementUsageWarningThresholdReachedEvent(KaitenModel): + """Triggered when an instance's entitlement usage crosses its configured early-warning + percentage boundary, before the cap is reached. Fires once per crossing. Delivered as + ``com.kaiten.instance.entitlement.v1.usage_warning_threshold_reached``. + """ + + name: Literal["INSTANCE_ENTITLEMENT_USAGE_WARNING_THRESHOLD_REACHED"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.usage_warning_threshold_reached"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementUsageWarningThresholdReached + """What the event is about.""" + + +class InstanceEntitlementValueGetEvent(KaitenModel): + """Triggered on every read of an instance entitlement's usage value. This is a read audit + signal, not a state change. Delivered as + ``com.kaiten.instance.entitlement.v1.value_get``. + """ + + name: Literal["ENTITLEMENT_VALUE_GET"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.value_get"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementValueGet + """What the event is about.""" + + +class InstanceCreatedEvent(KaitenModel): + """Triggered when a new instance is created. Delivered as ``com.kaiten.instance.v1.created``.""" + + name: Literal["INSTANCE_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.created"] + """The event type: what tells one event from another.""" + + data: Instance + """What the event is about.""" + + +class InstanceDeletedEvent(KaitenModel): + """Triggered when an instance is deleted. Delivered as ``com.kaiten.instance.v1.deleted``.""" + + name: Literal["INSTANCE_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.deleted"] + """The event type: what tells one event from another.""" + + data: Instance + """What the event is about.""" + + +class InstanceDeployedEvent(KaitenModel): + """Triggered when an instance is deployed to a deployment zone. Delivered as + ``com.kaiten.instance.v1.deployed``. + """ + + name: Literal["INSTANCE_DEPLOYED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.deployed"] + """The event type: what tells one event from another.""" + + data: Instance + """What the event is about.""" + + +class InstanceLifecycleStageChangedEvent(KaitenModel): + """Triggered when an instance commercial lifecycle stage changes. Delivered as + ``com.kaiten.instance.v1.lifecycle_stage_changed``. + """ + + name: Literal["INSTANCE_LIFECYCLE_STAGE_CHANGED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.lifecycle_stage_changed"] + """The event type: what tells one event from another.""" + + data: InstanceLifecycleStageChanged + """What the event is about.""" + + +class InstanceMigratedEvent(KaitenModel): + """Triggered when an instance is migrated from one deployment zone to another. Delivered as + ``com.kaiten.instance.v1.migrated``. + """ + + name: Literal["INSTANCE_MIGRATED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.migrated"] + """The event type: what tells one event from another.""" + + data: Instance + """What the event is about.""" + + +class InstanceStatusChangedEvent(KaitenModel): + """Triggered when an instance operational status changes. Delivered as + ``com.kaiten.instance.v1.status_changed``. + """ + + name: Literal["INSTANCE_STATUS_CHANGED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.status_changed"] + """The event type: what tells one event from another.""" + + data: InstanceStatusChanged + """What the event is about.""" + + +class InstanceUpdatedEvent(KaitenModel): + """Triggered when an instance is updated. Delivered as ``com.kaiten.instance.v1.updated``.""" + + name: Literal["INSTANCE_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.instance.v1.updated"] + """The event type: what tells one event from another.""" + + data: Instance + """What the event is about.""" + + +class LicenseEntitlementAssignedEvent(KaitenModel): + """Triggered when an entitlement is assigned to a license. Delivered as + ``com.kaiten.license.entitlement.v1.assigned``. + """ + + name: Literal["LICENSE_ENTITLEMENT_ASSIGNED"] + """The event name.""" + + type: Literal["com.kaiten.license.entitlement.v1.assigned"] + """The event type: what tells one event from another.""" + + data: LicenseEntitlement + """What the event is about.""" + + +class LicenseEntitlementUnassignedEvent(KaitenModel): + """Triggered when an entitlement is unassigned from a license. Delivered as + ``com.kaiten.license.entitlement.v1.unassigned``. + """ + + name: Literal["LICENSE_ENTITLEMENT_UNASSIGNED"] + """The event name.""" + + type: Literal["com.kaiten.license.entitlement.v1.unassigned"] + """The event type: what tells one event from another.""" + + data: LicenseEntitlement + """What the event is about.""" + + +class LicenseEntitlementUpdatedEvent(KaitenModel): + """Triggered when a license entitlement is updated. Delivered as + ``com.kaiten.license.entitlement.v1.updated``. + """ + + name: Literal["LICENSE_ENTITLEMENT_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.license.entitlement.v1.updated"] + """The event type: what tells one event from another.""" + + data: LicenseEntitlement + """What the event is about.""" + + +class LicenseArchivedEvent(KaitenModel): + """Triggered when a published license version is archived and withdrawn from sale. + Delivered as ``com.kaiten.license.v1.archived``. + """ + + name: Literal["LICENSE_ARCHIVED"] + """The event name.""" + + type: Literal["com.kaiten.license.v1.archived"] + """The event type: what tells one event from another.""" + + data: License + """What the event is about.""" + + +class LicenseCreatedEvent(KaitenModel): + """Triggered when a new license is created. Delivered as ``com.kaiten.license.v1.created``.""" + + name: Literal["LICENSE_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.license.v1.created"] + """The event type: what tells one event from another.""" + + data: License + """What the event is about.""" + + +class LicenseDeletedEvent(KaitenModel): + """Triggered when a license is deleted. Delivered as ``com.kaiten.license.v1.deleted``.""" + + name: Literal["LICENSE_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.license.v1.deleted"] + """The event type: what tells one event from another.""" + + data: License + """What the event is about.""" + + +class LicensePublishedEvent(KaitenModel): + """Triggered when a draft license version is published and goes on sale. Delivered as + ``com.kaiten.license.v1.published``. + """ + + name: Literal["LICENSE_PUBLISHED"] + """The event name.""" + + type: Literal["com.kaiten.license.v1.published"] + """The event type: what tells one event from another.""" + + data: License + """What the event is about.""" + + +class LicenseUnarchivedEvent(KaitenModel): + """Triggered when an archived license version is put back on sale. Delivered as + ``com.kaiten.license.v1.unarchived``. + """ + + name: Literal["LICENSE_UNARCHIVED"] + """The event name.""" + + type: Literal["com.kaiten.license.v1.unarchived"] + """The event type: what tells one event from another.""" + + data: License + """What the event is about.""" + + +class LicenseUpdatedEvent(KaitenModel): + """Triggered when a license is updated. Delivered as ``com.kaiten.license.v1.updated``.""" + + name: Literal["LICENSE_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.license.v1.updated"] + """The event type: what tells one event from another.""" + + data: License + """What the event is about.""" + + +class LicenseFamilyCreatedEvent(KaitenModel): + """Triggered when a license is created without naming a family: its version 1 opens a new + family, which this event carries, with the version it serves -- none if that version is + a draft. It is recorded in the same transaction as that version's LICENSE_CREATED. + Delivered as ``com.kaiten.license_family.v1.created``. + """ + + name: Literal["LICENSE_FAMILY_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.license_family.v1.created"] + """The event type: what tells one event from another.""" + + data: LicenseFamilyView + """What the event is about.""" + + +class LicenseFamilyDeletedEvent(KaitenModel): + """Triggered when a license family's last version is deleted, which deletes the family and + frees its slug. It carries the family as it stood before that version went, and is + recorded in the same transaction as that version's LICENSE_DELETED. Delivered as + ``com.kaiten.license_family.v1.deleted``. + """ + + name: Literal["LICENSE_FAMILY_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.license_family.v1.deleted"] + """The event type: what tells one event from another.""" + + data: LicenseFamilyView + """What the event is about.""" + + +class LicenseFamilyUpdatedEvent(KaitenModel): + """Triggered when a license family starts serving another version, or none: a newer version + published, a version published, archived or unarchived, the family's default taken or + given up, or the served version deleted. It carries the family with the version it + serves now, and is recorded in the same transaction as the version event that caused the + change. A change that leaves the served version where it was -- a draft added, a version + renamed, a default restated -- triggers none. Delivered as + ``com.kaiten.license_family.v1.updated``. + """ + + name: Literal["LICENSE_FAMILY_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.license_family.v1.updated"] + """The event type: what tells one event from another.""" + + data: LicenseFamilyView + """What the event is about.""" + + +class MetadataFieldArchivedEvent(KaitenModel): + """Triggered when a metadata field is archived and leaves the active schema composition. + Delivered as ``com.kaiten.metadata_field.v1.archived``. + """ + + name: Literal["METADATA_FIELD_ARCHIVED"] + """The event name.""" + + type: Literal["com.kaiten.metadata_field.v1.archived"] + """The event type: what tells one event from another.""" + + data: MetadataField + """What the event is about.""" + + +class MetadataFieldCreatedEvent(KaitenModel): + """Triggered when a metadata field is declared for a resource type. Delivered as + ``com.kaiten.metadata_field.v1.created``. + """ + + name: Literal["METADATA_FIELD_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.metadata_field.v1.created"] + """The event type: what tells one event from another.""" + + data: MetadataField + """What the event is about.""" + + +class MetadataFieldReorderedEvent(KaitenModel): + """Triggered when the display order of a resource type's metadata fields is rewritten. + Delivered as ``com.kaiten.metadata_field.v1.reordered``. + """ + + name: Literal["METADATA_FIELD_REORDERED"] + """The event name.""" + + type: Literal["com.kaiten.metadata_field.v1.reordered"] + """The event type: what tells one event from another.""" + + data: MetadataFieldReordered + """What the event is about.""" + + +class MetadataFieldUnarchivedEvent(KaitenModel): + """Triggered when an archived metadata field is restored to the active schema composition. + Delivered as ``com.kaiten.metadata_field.v1.unarchived``. + """ + + name: Literal["METADATA_FIELD_UNARCHIVED"] + """The event name.""" + + type: Literal["com.kaiten.metadata_field.v1.unarchived"] + """The event type: what tells one event from another.""" + + data: MetadataField + """What the event is about.""" + + +class MetadataFieldUpdatedEvent(KaitenModel): + """Triggered when a metadata field's label or JSON schema changes. A reordering emits + METADATA_FIELD_REORDERED instead. Delivered as ``com.kaiten.metadata_field.v1.updated``. + """ + + name: Literal["METADATA_FIELD_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.metadata_field.v1.updated"] + """The event type: what tells one event from another.""" + + data: MetadataField + """What the event is about.""" + + +class ReleaseCreatedEvent(KaitenModel): + """Triggered when a new release is created. Delivered as ``com.kaiten.release.v1.created``.""" + + name: Literal["RELEASE_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.release.v1.created"] + """The event type: what tells one event from another.""" + + data: Release + """What the event is about.""" + + +class ReleaseDeletedEvent(KaitenModel): + """Triggered when a release is deleted. Delivered as ``com.kaiten.release.v1.deleted``.""" + + name: Literal["RELEASE_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.release.v1.deleted"] + """The event type: what tells one event from another.""" + + data: Release + """What the event is about.""" + + +WebhookEvent = Annotated[ + ComponentCreatedEvent + | ComponentDeletedEvent + | ComponentUpdatedEvent + | CustomerCreatedEvent + | CustomerCreationRejectedEvent + | CustomerDeletedEvent + | CustomerUpdatedEvent + | DeploymentZoneCreatedEvent + | DeploymentZoneDeletedEvent + | ReleaseDeployedEvent + | DeploymentZoneUpdatedEvent + | EntitlementCreatedEvent + | EntitlementDeletedEvent + | EntitlementUpdatedEvent + | EntitlementGroupCreatedEvent + | EntitlementGroupDeletedEvent + | EntitlementGroupUpdatedEvent + | FeatureFlagCreatedEvent + | FeatureFlagDeletedEvent + | FeatureFlagEvaluatedEvent + | FeatureFlagUpdatedEvent + | SystemOrganizationTokenIssuedEvent + | InstanceEntitlementCapExceededEvent + | InstanceEntitlementUsagePeriodRolledOverEvent + | InstanceEntitlementUsageReachedEvent + | InstanceEntitlementUsageReportAcceptedEvent + | InstanceEntitlementUsageReportRejectedEvent + | InstanceEntitlementUsageWarningThresholdReachedEvent + | InstanceEntitlementValueGetEvent + | InstanceCreatedEvent + | InstanceDeletedEvent + | InstanceDeployedEvent + | InstanceLifecycleStageChangedEvent + | InstanceMigratedEvent + | InstanceStatusChangedEvent + | InstanceUpdatedEvent + | LicenseEntitlementAssignedEvent + | LicenseEntitlementUnassignedEvent + | LicenseEntitlementUpdatedEvent + | LicenseArchivedEvent + | LicenseCreatedEvent + | LicenseDeletedEvent + | LicensePublishedEvent + | LicenseUnarchivedEvent + | LicenseUpdatedEvent + | LicenseFamilyCreatedEvent + | LicenseFamilyDeletedEvent + | LicenseFamilyUpdatedEvent + | MetadataFieldArchivedEvent + | MetadataFieldCreatedEvent + | MetadataFieldReorderedEvent + | MetadataFieldUnarchivedEvent + | MetadataFieldUpdatedEvent + | ReleaseCreatedEvent + | ReleaseDeletedEvent, + Field(discriminator="type"), +] +"""Any event Kaiten delivers to a webhook endpoint, discriminated by ``type``.""" + +WEBHOOK_EVENTS: dict[str, type[KaitenModel]] = { + "com.kaiten.component.v1.created": ComponentCreatedEvent, + "com.kaiten.component.v1.deleted": ComponentDeletedEvent, + "com.kaiten.component.v1.updated": ComponentUpdatedEvent, + "com.kaiten.customer.v1.created": CustomerCreatedEvent, + "com.kaiten.customer.v1.creation_rejected": CustomerCreationRejectedEvent, + "com.kaiten.customer.v1.deleted": CustomerDeletedEvent, + "com.kaiten.customer.v1.updated": CustomerUpdatedEvent, + "com.kaiten.deployment_zone.v1.created": DeploymentZoneCreatedEvent, + "com.kaiten.deployment_zone.v1.deleted": DeploymentZoneDeletedEvent, + "com.kaiten.deployment_zone.v1.release_deployed": ReleaseDeployedEvent, + "com.kaiten.deployment_zone.v1.updated": DeploymentZoneUpdatedEvent, + "com.kaiten.entitlement.v1.created": EntitlementCreatedEvent, + "com.kaiten.entitlement.v1.deleted": EntitlementDeletedEvent, + "com.kaiten.entitlement.v1.updated": EntitlementUpdatedEvent, + "com.kaiten.entitlement_group.v1.created": EntitlementGroupCreatedEvent, + "com.kaiten.entitlement_group.v1.deleted": EntitlementGroupDeletedEvent, + "com.kaiten.entitlement_group.v1.updated": EntitlementGroupUpdatedEvent, + "com.kaiten.feature_flag.v1.created": FeatureFlagCreatedEvent, + "com.kaiten.feature_flag.v1.deleted": FeatureFlagDeletedEvent, + "com.kaiten.feature_flag.v1.evaluated": FeatureFlagEvaluatedEvent, + "com.kaiten.feature_flag.v1.updated": FeatureFlagUpdatedEvent, + "com.kaiten.identity.v1.system_token_issued": SystemOrganizationTokenIssuedEvent, + "com.kaiten.instance.entitlement.v1.cap_exceeded": InstanceEntitlementCapExceededEvent, + "com.kaiten.instance.entitlement.v1.usage_period_rolled_over": InstanceEntitlementUsagePeriodRolledOverEvent, + "com.kaiten.instance.entitlement.v1.usage_reached": InstanceEntitlementUsageReachedEvent, + "com.kaiten.instance.entitlement.v1.usage_report_accepted": InstanceEntitlementUsageReportAcceptedEvent, + "com.kaiten.instance.entitlement.v1.usage_report_rejected": InstanceEntitlementUsageReportRejectedEvent, + "com.kaiten.instance.entitlement.v1.usage_warning_threshold_reached": InstanceEntitlementUsageWarningThresholdReachedEvent, + "com.kaiten.instance.entitlement.v1.value_get": InstanceEntitlementValueGetEvent, + "com.kaiten.instance.v1.created": InstanceCreatedEvent, + "com.kaiten.instance.v1.deleted": InstanceDeletedEvent, + "com.kaiten.instance.v1.deployed": InstanceDeployedEvent, + "com.kaiten.instance.v1.lifecycle_stage_changed": InstanceLifecycleStageChangedEvent, + "com.kaiten.instance.v1.migrated": InstanceMigratedEvent, + "com.kaiten.instance.v1.status_changed": InstanceStatusChangedEvent, + "com.kaiten.instance.v1.updated": InstanceUpdatedEvent, + "com.kaiten.license.entitlement.v1.assigned": LicenseEntitlementAssignedEvent, + "com.kaiten.license.entitlement.v1.unassigned": LicenseEntitlementUnassignedEvent, + "com.kaiten.license.entitlement.v1.updated": LicenseEntitlementUpdatedEvent, + "com.kaiten.license.v1.archived": LicenseArchivedEvent, + "com.kaiten.license.v1.created": LicenseCreatedEvent, + "com.kaiten.license.v1.deleted": LicenseDeletedEvent, + "com.kaiten.license.v1.published": LicensePublishedEvent, + "com.kaiten.license.v1.unarchived": LicenseUnarchivedEvent, + "com.kaiten.license.v1.updated": LicenseUpdatedEvent, + "com.kaiten.license_family.v1.created": LicenseFamilyCreatedEvent, + "com.kaiten.license_family.v1.deleted": LicenseFamilyDeletedEvent, + "com.kaiten.license_family.v1.updated": LicenseFamilyUpdatedEvent, + "com.kaiten.metadata_field.v1.archived": MetadataFieldArchivedEvent, + "com.kaiten.metadata_field.v1.created": MetadataFieldCreatedEvent, + "com.kaiten.metadata_field.v1.reordered": MetadataFieldReorderedEvent, + "com.kaiten.metadata_field.v1.unarchived": MetadataFieldUnarchivedEvent, + "com.kaiten.metadata_field.v1.updated": MetadataFieldUpdatedEvent, + "com.kaiten.release.v1.created": ReleaseCreatedEvent, + "com.kaiten.release.v1.deleted": ReleaseDeletedEvent, +} +"""Every published event type, mapped to the model that parses it.""" diff --git a/src/kaitencloud/usage.py b/src/kaitencloud/usage.py new file mode 100644 index 0000000..c89dffb --- /dev/null +++ b/src/kaitencloud/usage.py @@ -0,0 +1,94 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The arithmetic of entitlement enforcement, as the Kaiten API applies it. + +A numeric grant carries two numbers -- its value and its overage allowance +(``limit_cap_exceeded_overage_percent``) -- and every boundary derives from them: + +================= ==================== ========================================= +Overage percent Meaning A usage report is refused when usage is +================= ==================== ========================================= +``-1`` unlimited (value -1) never +``0`` hard limit above the value +``10`` soft limit, +10% above the value plus 10% of it +================= ==================== ========================================= + +These helpers compute the boundaries the server enforces, so an application can render a +meter, or check a report before sending it, without another round trip:: + + from kaitencloud import usage + + grant = client.licenses.get_entitlement("growth", "seats") + cap = usage.maximum_allowed_usage(grant.value.value, grant.limit_cap_exceeded_overage_percent) + +A soft limit is not exhausted at its value: a customer with 100 seats and a 10% allowance is +served the 105th seat. :func:`percentage_used` reaches 100 where the customer consumed what +they bought, and keeps climbing through the allowance, while :func:`remaining` counts down to +where the server starts refusing. +""" + +from __future__ import annotations + +from typing import Final + +__all__ = [ + "UNLIMITED", + "accepts", + "is_hard_limit", + "is_soft_limit", + "is_unlimited", + "maximum_allowed_usage", + "percentage_used", + "remaining", +] + +UNLIMITED: Final = -1 +"""The grant value that means "no cap".""" + + +def is_unlimited(value: float) -> bool: + """Whether a grant ``value`` is the unlimited sentinel.""" + return value == UNLIMITED + + +def maximum_allowed_usage(value: float, overage_percent: int | None = 0) -> float | None: + """The most usage the API accepts against a grant: its value plus its allowance. + + ``None`` for an unlimited grant. Zero is a real cap, not an absence of one. + """ + if is_unlimited(value) or value < 0: + return None + return value + value * max(overage_percent or 0, 0) / 100 + + +def is_hard_limit(value: float, overage_percent: int | None) -> bool: + """Whether usage is refused as soon as it exceeds the grant's value.""" + return not is_unlimited(value) and (overage_percent or 0) <= 0 + + +def is_soft_limit(value: float, overage_percent: int | None) -> bool: + """Whether usage may exceed the grant's value by an allowance before being refused.""" + return not is_unlimited(value) and (overage_percent or 0) > 0 + + +def accepts(value: float, usage: float, overage_percent: int | None = 0) -> bool: + """Whether the API accepts a report that leaves usage at ``usage``.""" + cap = maximum_allowed_usage(value, overage_percent) + return cap is None or usage <= cap + + +def remaining(value: float, used: float, overage_percent: int | None = 0) -> float | None: + """How much more can be used before reports are refused; ``None`` when unlimited.""" + cap = maximum_allowed_usage(value, overage_percent) + return None if cap is None else max(cap - used, 0.0) + + +def percentage_used(value: float, used: float) -> float | None: + """Usage as a percentage of the grant's value -- not of its cap, so it can exceed 100. + + ``None`` when the grant is unlimited or its value is zero. + """ + if is_unlimited(value) or value <= 0: + return None + return used / value * 100 diff --git a/src/kaitencloud/webhooks.py b/src/kaitencloud/webhooks.py new file mode 100644 index 0000000..a99c966 --- /dev/null +++ b/src/kaitencloud/webhooks.py @@ -0,0 +1,189 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Verify and parse the webhooks Kaiten delivers. + +Kaiten delivers webhooks through Svix. Every delivery carries ``svix-id``, ``svix-timestamp`` +and ``svix-signature`` headers, signed with your endpoint's ``whsec_...`` secret. Verify the +*raw* request body -- before any JSON parsing, which would change the bytes signed -- then act +on the typed event:: + + from kaitencloud.webhooks import ( + InstanceEntitlementCapExceededEvent, + WebhookVerificationError, + verify_webhook, + ) + + @app.post("/webhooks/kaiten") + async def kaiten_webhook(request: Request) -> Response: + try: + event = verify_webhook(await request.body(), request.headers, secret=SIGNING_SECRET) + except WebhookVerificationError: + return Response(status_code=400) + + if isinstance(event, InstanceEntitlementCapExceededEvent): + notify_sales(event.data.entitlement_slug, event.data.overage) + return Response(status_code=204) + +Every event published by the API has a model here, named after it. An event newer than the +SDK you run parses as :class:`UnknownWebhookEvent` rather than failing. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import time +from collections.abc import Mapping +from datetime import timedelta +from typing import Any, Final + +from pydantic import ValidationError + +from ._exceptions import WebhookPayloadError, WebhookVerificationError +from ._models import KaitenModel +from .types import _webhooks +from .types._webhooks import * # noqa: F403 -- every event model is part of this module's API +from .types._webhooks import WEBHOOK_EVENTS, WebhookEvent + +__all__ = [ + "DEFAULT_TOLERANCE", + "UnknownWebhookEvent", + "Webhook", + "WebhookPayloadError", + "WebhookVerificationError", + "parse_event", + "verify_webhook", +] +__all__ += _webhooks.__all__ + +DEFAULT_TOLERANCE: Final = timedelta(minutes=5) +"""How far a delivery's timestamp may be from now, in either direction, to be accepted.""" + +_SECRET_PREFIX = "whsec_" + + +class UnknownWebhookEvent(KaitenModel): + """An event this version of the SDK has no model for, typically one published after it.""" + + name: str + type: str + data: Any = None + + +class Webhook: + """A verifier for one webhook endpoint's signing secret. + + Build it once and reuse it: the secret is decoded, and validated, on construction. + + Args: + secret: The endpoint's signing secret, ``whsec_...``. + tolerance: How far a delivery's timestamp may be from now, either way, before it is + refused as a replay. Defaults to five minutes. + """ + + def __init__(self, secret: str, *, tolerance: timedelta | float = DEFAULT_TOLERANCE) -> None: + self._key = _decode_secret(secret) + self._tolerance = ( + tolerance.total_seconds() if isinstance(tolerance, timedelta) else float(tolerance) + ) + + def verify( + self, payload: bytes | str, headers: Mapping[str, str] + ) -> WebhookEvent | UnknownWebhookEvent: + """Verify a delivery and return its event. + + Args: + payload: The raw request body, exactly as received. + headers: The request headers. Any mapping works; names are matched + case-insensitively. + + Raises: + WebhookVerificationError: A header is missing, the timestamp is outside the + tolerance, or no signature matches. Answer 400 and do not act on it. + WebhookPayloadError: The delivery is authentic but does not match the contract of + the event it names. + """ + body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload) + lowered = {name.lower(): value for name, value in headers.items()} + message_id = _header(lowered, "id") + timestamp = _header(lowered, "timestamp") + signatures = _header(lowered, "signature") + + self._check_timestamp(timestamp) + signed = f"{message_id}.{timestamp}.".encode() + body + expected = base64.b64encode(hmac.new(self._key, signed, hashlib.sha256).digest()).decode() + for candidate in signatures.split(): + version, _, signature = candidate.partition(",") + if version == "v1" and hmac.compare_digest(signature, expected): + return parse_event(body) + raise WebhookVerificationError("No signature matches the payload.") + + def _check_timestamp(self, timestamp: str) -> None: + try: + sent = int(timestamp) + except ValueError: + raise WebhookVerificationError(f"Invalid timestamp header: {timestamp!r}") from None + now = time.time() + if sent < now - self._tolerance: + raise WebhookVerificationError("The delivery's timestamp is too old.") + if sent > now + self._tolerance: + raise WebhookVerificationError("The delivery's timestamp is in the future.") + + +def verify_webhook( + payload: bytes | str, + headers: Mapping[str, str], + *, + secret: str, + tolerance: timedelta | float = DEFAULT_TOLERANCE, +) -> WebhookEvent | UnknownWebhookEvent: + """Verify a delivery with ``secret`` and return its event. See :meth:`Webhook.verify`.""" + return Webhook(secret, tolerance=tolerance).verify(payload, headers) + + +def parse_event(payload: bytes | str | Mapping[str, Any]) -> WebhookEvent | UnknownWebhookEvent: + """Parse an event without verifying it -- for tests, or a payload verified upstream.""" + if isinstance(payload, Mapping): + data: Any = dict(payload) + else: + try: + data = json.loads(payload) + except ValueError as error: + raise WebhookPayloadError("The webhook payload is not JSON.") from error + if not isinstance(data, dict): + raise WebhookPayloadError("The webhook payload is not a JSON object.") + + model = WEBHOOK_EVENTS.get(str(data.get("type"))) + try: + if model is None: + return UnknownWebhookEvent.model_validate(data) + event: WebhookEvent = model.model_validate(data) # type: ignore[assignment] + except ValidationError as error: + raise WebhookPayloadError( + f"The webhook payload does not match the {data.get('type')!r} event: {error}" + ) from error + return event + + +def _decode_secret(secret: str) -> bytes: + value = secret.strip() + if value.startswith(_SECRET_PREFIX): + value = value[len(_SECRET_PREFIX) :] + try: + key = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError): + key = b"" + if not key: + raise ValueError("The webhook signing secret is not a valid whsec_... secret.") + return key + + +def _header(headers: Mapping[str, str], name: str) -> str: + value = headers.get(f"svix-{name}") or headers.get(f"webhook-{name}") + if not value: + raise WebhookVerificationError(f"The svix-{name} header is missing.") + return value diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dcc8c3e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,97 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Fixtures: clients wired to in-process fake APIs, so no test opens a socket.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from helpers import BASE_URL, PLATFORM_URL, Handler, Recorder, recording + +from kaitencloud import ( + AsyncKaitenClient, + AsyncKaitenPlatformClient, + KaitenClient, + KaitenPlatformClient, +) + + +@pytest.fixture(autouse=True) +def _isolated_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep a developer's own Kaiten configuration out of every test.""" + for name in ( + "KAITEN_BASE_URL", + "KAITEN_AUTH_TOKEN", + "KAITEN_PLATFORM_BASE_URL", + "KAITEN_PLATFORM_TOKEN", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def delays(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """The retry delays the clients asked for, recorded instead of slept.""" + recorded: list[float] = [] + + async def async_sleep(seconds: float) -> None: + recorded.append(seconds) + + monkeypatch.setattr("kaitencloud._sync._http.sleep", recorded.append) + monkeypatch.setattr("kaitencloud._async._http.async_sleep", async_sleep) + return recorded + + +@pytest.fixture +def make_client(delays: list[float]) -> Callable[..., tuple[KaitenClient, Recorder]]: + def factory(handler: Handler, **options: Any) -> tuple[KaitenClient, Recorder]: + recorder = Recorder() + options.setdefault("token", "ksh_test") + options.setdefault("base_url", BASE_URL) + http_client = httpx.Client(transport=recording(handler, recorder)) + return KaitenClient(http_client=http_client, **options), recorder + + return factory + + +@pytest.fixture +def make_async_client(delays: list[float]) -> Callable[..., tuple[AsyncKaitenClient, Recorder]]: + def factory(handler: Handler, **options: Any) -> tuple[AsyncKaitenClient, Recorder]: + recorder = Recorder() + options.setdefault("token", "ksh_test") + options.setdefault("base_url", BASE_URL) + http_client = httpx.AsyncClient(transport=recording(handler, recorder)) + return AsyncKaitenClient(http_client=http_client, **options), recorder + + return factory + + +@pytest.fixture +def make_platform_client( + delays: list[float], +) -> Callable[..., tuple[KaitenPlatformClient, Recorder]]: + def factory(handler: Handler, **options: Any) -> tuple[KaitenPlatformClient, Recorder]: + recorder = Recorder() + options.setdefault("token", "ksm_test") + options.setdefault("base_url", PLATFORM_URL) + http_client = httpx.Client(transport=recording(handler, recorder)) + return KaitenPlatformClient(http_client=http_client, **options), recorder + + return factory + + +@pytest.fixture +def make_async_platform_client( + delays: list[float], +) -> Callable[..., tuple[AsyncKaitenPlatformClient, Recorder]]: + def factory(handler: Handler, **options: Any) -> tuple[AsyncKaitenPlatformClient, Recorder]: + recorder = Recorder() + options.setdefault("token", "ksm_test") + options.setdefault("base_url", PLATFORM_URL) + http_client = httpx.AsyncClient(transport=recording(handler, recorder)) + return AsyncKaitenPlatformClient(http_client=http_client, **options), recorder + + return factory diff --git a/tests/contract.py b/tests/contract.py new file mode 100644 index 0000000..43452a5 --- /dev/null +++ b/tests/contract.py @@ -0,0 +1,218 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The contract snapshots as test tooling: operations, write-side validation, sample payloads.""" + +from __future__ import annotations + +import copy +import functools +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx +import yaml +from helpers import Handler +from jsonschema import Draft202012Validator +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +ROOT = Path(__file__).resolve().parent.parent +SPECS = { + "core": ROOT / "openapi" / "openapi.yaml", + "platform": ROOT / "openapi" / "platform-openapi.yaml", +} +HTTP_METHODS = ("get", "put", "post", "patch", "delete") + +SAMPLE_TIME = "2026-09-14T12:00:00Z" +SAMPLE_UUID = "3f6b1a2c-7c1e-4d0b-9d5f-2b0a1c4e8d31" + + +@functools.cache +def document(surface: str) -> dict[str, Any]: + return yaml.safe_load(SPECS[surface].read_text(encoding="utf-8")) + + +@functools.cache +def coverage() -> dict[str, Any]: + return yaml.safe_load((ROOT / "openapi" / "coverage.yaml").read_text(encoding="utf-8")) + + +@dataclass(frozen=True, eq=False) +class Operation: + surface: str + method: str + path: str + operation_id: str + definition: dict[str, Any] + + @property + def pattern(self) -> re.Pattern[str]: + segments = [ + "[^/]+" if segment.startswith("{") and segment.endswith("}") else re.escape(segment) + for segment in self.path.split("/") + ] + return re.compile("^" + "/".join(segments) + "$") + + @property + def query_parameters(self) -> set[str]: + return { + parameter["name"] + for parameter in self.definition.get("parameters", []) + if parameter.get("in") == "query" + } + + @property + def request_schema(self) -> dict[str, Any] | None: + content = (self.definition.get("requestBody") or {}).get("content") or {} + media = content.get("application/json") + return media["schema"] if media else None + + def success(self) -> tuple[int, dict[str, Any] | None]: + """The operation's first success status, and the schema of its body if it has one.""" + for code in sorted(self.definition["responses"]): + if code.startswith("2"): + media = (self.definition["responses"][code].get("content") or {}).get( + "application/json" + ) + return int(code), (media["schema"] if media else None) + raise AssertionError(f"{self.operation_id} declares no success response") + + +@functools.cache +def operations(surface: str) -> tuple[Operation, ...]: + found = [ + Operation(surface, method.upper(), path, item[method]["operationId"], item[method]) + for path, item in document(surface)["paths"].items() + for method in HTTP_METHODS + if method in item + ] + # Literal paths before templated ones, so /metadata-fields/reorder never reads as an id. + return tuple(sorted(found, key=lambda operation: operation.path.count("{"))) + + +def find_operation(surface: str, method: str, url_path: str) -> Operation: + """The operation a request addresses, from its method and its path under ``/api``.""" + assert url_path.startswith("/api/"), f"{url_path} is not under /api/" + relative = url_path[len("/api") :] + for operation in operations(surface): + if operation.method == method and operation.pattern.match(relative): + return operation + raise AssertionError(f"{method} {url_path} is no operation of the {surface} contract") + + +def write_errors(surface: str, schema: dict[str, Any], body: Any) -> list[str]: + """Why the API would refuse ``body`` as a request against ``schema``: empty when it would not.""" + validator = Draft202012Validator( + {"$ref": f"urn:kaiten:{surface}{schema['$ref']}"}, registry=_write_registry(surface) + ) + return sorted( + f"{'/'.join(map(str, error.absolute_path)) or ''}: {error.message}" + for error in validator.iter_errors(body) + ) + + +@functools.cache +def _write_registry(surface: str) -> Registry: + """The contract as a JSON Schema resource, with the write-side rules applied. + + The API validates a request against the resource schema with its readOnly properties no + longer required -- and a client has no business sending one -- so readOnly properties are + dropped outright. Every body is ``additionalProperties: false``, so a request carrying one + then fails exactly like a misspelled key. + """ + writable = _without_read_only(copy.deepcopy(document(surface))) + resource = Resource.from_contents(writable, default_specification=DRAFT202012) + return Registry().with_resource(f"urn:kaiten:{surface}", resource) + + +def _without_read_only(node: Any) -> Any: + if isinstance(node, dict): + properties = node.get("properties") + if isinstance(properties, dict): + read_only = { + name + for name, schema in properties.items() + if isinstance(schema, dict) and schema.get("readOnly") + } + for name in read_only: + del properties[name] + if isinstance(node.get("required"), list): + node["required"] = [name for name in node["required"] if name not in read_only] + for value in node.values(): + _without_read_only(value) + elif isinstance(node, list): + for value in node: + _without_read_only(value) + return node + + +def sample( + schema: Any, surface: str, *, full: bool = True, seen: frozenset[str] = frozenset() +) -> Any: + """A value ``schema`` describes: with every property when ``full``, only required ones otherwise.""" + if not isinstance(schema, dict): + return "anything" + if "$ref" in schema: + name = schema["$ref"].rsplit("/", 1)[-1] + if name in seen: + return None + target = document(surface)["components"]["schemas"][name] + return sample(target, surface, full=full, seen=seen | {name}) + for key in ("oneOf", "anyOf"): + if key in schema: + return sample(schema[key][0], surface, full=full, seen=seen) + if schema.get("enum"): + return schema["enum"][0] + + kind = schema.get("type") + if isinstance(kind, list): + kind = next((item for item in kind if item != "null"), None) + if kind is None and "properties" in schema: + kind = "object" + + if kind == "string": + return {"date-time": SAMPLE_TIME, "uuid": SAMPLE_UUID, "uri": "https://kaiten.test/p"}.get( + schema.get("format", ""), "sample" + ) + if kind == "integer": + return 1 + if kind == "number": + return 1.5 + if kind == "boolean": + # False, so a sampled page never claims more rows than it holds. + return False + if kind == "array": + return [sample(schema.get("items"), surface, full=full, seen=seen)] if full else [] + if kind == "object": + properties: dict[str, Any] = schema.get("properties") or {} + required = set(schema.get("required") or []) + value = { + name: sample(node, surface, full=full, seen=seen) + for name, node in properties.items() + if full or name in required + } + extra = schema.get("additionalProperties") + if not properties and full: + value["key"] = ( + sample(extra, surface, full=full, seen=seen) + if isinstance(extra, dict) and extra + else "value" + ) + return value + return "anything" + + +def contract_api(surface: str) -> Handler: + """A fake API answering every operation the way the contract describes it.""" + + def handler(request: httpx.Request) -> httpx.Response: + operation = find_operation(surface, request.method, request.url.path) + status, schema = operation.success() + if schema is None: + return httpx.Response(status) + return httpx.Response(status, json=sample(schema, surface)) + + return handler diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..aaf69bd --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,59 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Test helpers: a recording fake API, and the values tests share.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx + +BASE_URL = "https://kaiten.test" +PLATFORM_URL = "http://kaiten-api.internal:6001" +ORG_ID = "3f6b1a2c-7c1e-4d0b-9d5f-2b0a1c4e8d31" +USER_ID = "8a1f0e2b-1c3d-4e5f-9a7b-6c5d4e3f2a1b" + +Handler = Callable[[httpx.Request], httpx.Response] + + +class Recorder(list[httpx.Request]): + """Every request a fake API received, in order.""" + + @property + def only(self) -> httpx.Request: + assert len(self) == 1, f"expected exactly one request, got {len(self)}" + return self[0] + + +def body(request: httpx.Request) -> Any: + """The JSON body ``request`` carried, or ``None``.""" + return json.loads(request.content) if request.content else None + + +def respond( + status: int = 200, payload: Any = None, *, headers: dict[str, str] | None = None +) -> Handler: + """A fake API answering every request with ``status`` and ``payload``.""" + + def handler(request: httpx.Request) -> httpx.Response: + if payload is None: + return httpx.Response(status, headers=headers) + if isinstance(payload, (str, bytes)): + return httpx.Response(status, content=payload, headers=headers) + return httpx.Response(status, json=payload, headers=headers) + + return handler + + +def recording(handler: Handler, recorder: Recorder) -> httpx.MockTransport: + """A transport that records each request, then lets ``handler`` answer it.""" + + def record(request: httpx.Request) -> httpx.Response: + request.read() + recorder.append(request) + return handler(request) + + return httpx.MockTransport(record) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f5d3490 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,218 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Building clients: credentials, base URLs, headers, options and lifecycle.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from helpers import BASE_URL, PLATFORM_URL, Recorder, recording, respond + +import kaitencloud +from kaitencloud import ( + AsyncKaitenClient, + CredentialError, + KaitenClient, + KaitenPlatformClient, +) + +EMPTY_PAGE = {"items": [], "hasMore": False} + + +@pytest.mark.parametrize( + ("given", "expected"), + [ + ("https://kaiten.example.com", "https://kaiten.example.com/api/"), + ("https://kaiten.example.com/", "https://kaiten.example.com/api/"), + ("https://kaiten.example.com/api", "https://kaiten.example.com/api/"), + ("https://kaiten.example.com/api/", "https://kaiten.example.com/api/"), + ("http://localhost:6000", "http://localhost:6000/api/"), + ("https://example.com/kaiten/api", "https://example.com/kaiten/api/"), + (" https://example.com/prefix ", "https://example.com/prefix/api/"), + ], +) +def test_the_base_url_always_ends_under_api(given: str, expected: str) -> None: + assert KaitenClient(token="ksh_x", base_url=given).base_url == expected + + +@pytest.mark.parametrize("given", ["kaiten.example.com", "ftp://kaiten.test", "/api"]) +def test_the_base_url_must_be_an_absolute_http_url(given: str) -> None: + with pytest.raises(ValueError, match="base_url"): + KaitenClient(token="ksh_x", base_url=given) + + +@pytest.mark.parametrize("configured", [None, "", " "], ids=["unset", "empty", "blank"]) +def test_there_is_no_default_address( + monkeypatch: pytest.MonkeyPatch, configured: str | None +) -> None: + """Every deployment has its own address, so the SDK never guesses one to send a token to.""" + if configured is None: + monkeypatch.delenv("KAITEN_BASE_URL", raising=False) + else: + monkeypatch.setenv("KAITEN_BASE_URL", configured) + with pytest.raises(ValueError, match="KAITEN_BASE_URL"): + KaitenClient(token="ksh_x") + + +def test_the_environment_provides_the_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KAITEN_BASE_URL", "https://kaiten.acme.internal") + monkeypatch.setenv("KAITEN_AUTH_TOKEN", "ksh_from_env") + recorder = Recorder() + http_client = httpx.Client(transport=recording(respond(200, EMPTY_PAGE), recorder)) + + client = KaitenClient(http_client=http_client) + client.customers.list() + + assert client.base_url == "https://kaiten.acme.internal/api/" + assert recorder.only.headers["authorization"] == "Bearer ksh_from_env" + + +def test_a_client_without_a_credential_is_refused() -> None: + with pytest.raises(CredentialError, match="KAITEN_AUTH_TOKEN"): + KaitenClient() + + +def test_the_platform_client_has_no_default_address() -> None: + with pytest.raises(ValueError, match="KAITEN_PLATFORM_BASE_URL"): + KaitenPlatformClient(token="ksm_x") + + +def test_the_platform_client_reads_its_own_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KAITEN_PLATFORM_BASE_URL", PLATFORM_URL) + monkeypatch.setenv("KAITEN_PLATFORM_TOKEN", "ksm_from_env") + assert KaitenPlatformClient().base_url == f"{PLATFORM_URL}/api/" + + +def test_requests_carry_the_credential_and_name_the_sdk(make_client: Callable[..., Any]) -> None: + client, recorder = make_client( + respond(200, EMPTY_PAGE), default_headers={"X-Request-Source": "billing"} + ) + client.customers.list() + + request = recorder.only + assert str(request.url).startswith(f"{BASE_URL}/api/customers?") + assert request.headers["authorization"] == "Bearer ksh_test" + assert request.headers["accept"] == "application/json" + assert request.headers["user-agent"].startswith(f"kaitencloud-python/{kaitencloud.__version__}") + assert request.headers["x-request-source"] == "billing" + + +def test_default_headers_cannot_replace_the_credential(make_client: Callable[..., Any]) -> None: + client, recorder = make_client( + respond(200, EMPTY_PAGE), default_headers={"Authorization": "Bearer ksh_other"} + ) + client.customers.list() + assert recorder.only.headers["authorization"] == "Bearer ksh_test" + + +def test_a_token_provider_is_asked_before_every_request(make_client: Callable[..., Any]) -> None: + tokens = iter(["ksh_first", "ksh_rotated"]) + client, recorder = make_client(respond(200, EMPTY_PAGE), token=lambda: next(tokens)) + + client.customers.list() + client.customers.list() + + assert [request.headers["authorization"] for request in recorder] == [ + "Bearer ksh_first", + "Bearer ksh_rotated", + ] + + +def test_an_async_token_provider_can_be_a_coroutine_function( + make_async_client: Callable[..., Any], +) -> None: + async def provide() -> str: + return "ksh_from_vault" + + async def scenario() -> Recorder: + client, recorder = make_async_client(respond(200, EMPTY_PAGE), token=provide) + await client.customers.list() + return recorder + + assert asyncio.run(scenario()).only.headers["authorization"] == "Bearer ksh_from_vault" + + +@pytest.mark.parametrize("token", ["ksm_platform", "whsec_c2lnbmluZw=="]) +def test_the_core_client_refuses_other_credentials_before_sending( + make_client: Callable[..., Any], token: str +) -> None: + client, recorder = make_client(respond(200, EMPTY_PAGE), token=token) + with pytest.raises(CredentialError): + client.customers.list() + assert not recorder + + +def test_a_rotated_token_of_the_wrong_class_is_caught_too(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(200, EMPTY_PAGE), token=lambda: "ksm_rotated") + with pytest.raises(CredentialError, match="KaitenPlatformClient"): + client.customers.list() + assert not recorder + + +@pytest.mark.parametrize("token", ["ksh_organization", "eyJhbGciOiJIUzI1NiJ9.e30.c2ln"]) +def test_the_platform_client_accepts_only_platform_credentials( + make_platform_client: Callable[..., Any], token: str +) -> None: + client, recorder = make_platform_client(respond(200, {}), token=token) + with pytest.raises(CredentialError, match="ksm_"): + client.me() + assert not recorder + + +def test_with_options_changes_only_what_it_names_and_shares_the_pool( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(respond(200, EMPTY_PAGE), max_retries=3) + + tuned = client.with_options(max_retries=0, timeout=5.0) + tuned.customers.list() + + assert (client.max_retries, tuned.max_retries) == (3, 0) + assert tuned.base_url == client.base_url + assert tuned._api.http_client is client._api.http_client + assert tuned.customers is not client.customers + assert recorder.only.extensions["timeout"] == httpx.Timeout(5.0).as_dict() + + +def test_closing_a_client_leaves_an_injected_pool_open() -> None: + injected = httpx.Client(transport=httpx.MockTransport(respond(200, EMPTY_PAGE))) + with KaitenClient(token="ksh_x", base_url=BASE_URL, http_client=injected) as client: + client.customers.list() + assert not injected.is_closed + + +def test_closing_a_client_closes_the_pool_it_built() -> None: + client = KaitenClient(token="ksh_x", base_url=BASE_URL) + client.close() + assert client._api.http_client.is_closed + + +def test_the_async_client_is_an_async_context_manager() -> None: + async def scenario() -> bool: + async with AsyncKaitenClient(token="ksh_x", base_url=BASE_URL) as client: + pass + return client._api.http_client.is_closed + + assert asyncio.run(scenario()) + + +def test_negative_retries_are_refused() -> None: + with pytest.raises(ValueError, match="max_retries"): + KaitenClient(token="ksh_x", base_url=BASE_URL, max_retries=-1) + + +def test_a_client_repr_names_its_base_url() -> None: + client = KaitenClient(token="ksh_x", base_url=BASE_URL) + assert repr(client) == "KaitenClient(base_url='https://kaiten.test/api/')" + assert repr(client.customers) == "" + + +def test_the_webhooks_module_loads_on_first_access() -> None: + assert kaitencloud.webhooks.verify_webhook.__name__ == "verify_webhook" + with pytest.raises(AttributeError): + _ = kaitencloud.not_a_module # type: ignore[attr-defined] diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..0ce28e0 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,709 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Every SDK method, checked against the contract operation it calls. + +Each case calls a method against a fake API that answers the way the contract describes -- the +operation's success status, with a payload generated from its response schema -- then checks +what went on the wire: + +- the request addresses the operation the method is mapped to, under ``/api``; +- its query parameters are ones that operation declares; +- its body validates against the operation's request schema with the write-side rules + applied: no readOnly property, and every required writable one. Every Kaiten request body + is ``additionalProperties: false``, so a key too many and a required key missing end the + same way -- a 422 on every call, with no type error to catch it; +- the rules the schema cannot express hold: a slug an update refuses, a license version a + create refuses. + +A ``-minimal`` variant calls the method with its required arguments only, which is where a +required-but-nullable key left out would show. Both clients run the whole table: the async +source, and the sync client generated from it. Along the way every response model parses a +payload shaped like the contract's, since each call returns one. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +from contract import contract_api, find_operation, write_errors +from helpers import ORG_ID, USER_ID, Recorder, body + +from kaitencloud import targeting + +ID = "5d1e8f3a-2b4c-4d6e-8f0a-1b2c3d4e5f60" +SAMPLE_ID = "0f9e8d7c-6b5a-4938-8271-605f4e3d2c1b" +NOW = datetime(2026, 9, 14, 12, 0, tzinfo=timezone.utc) +LATER = NOW + timedelta(days=30) +CONNECTOR = "kaiten.integration.crm.attio" +INTEGRATION = { + "external_id": "rec_1", + "metadata": {"stage": "won"}, + "web_url": "https://app.attio.com/rec_1", + "last_error": "timeout", +} +VARIANTS = [targeting.variant("on", True, description="Enabled"), targeting.variant("off", False)] +RULES = [ + targeting.rule("Growth plan", "__kaiten.license.slug == 'growth'", "on"), + targeting.split_rule("Progressive rollout", "true", {"on": 20, "off": 80}), + targeting.schedule_rule( + "Launch", "true", start=targeting.step("on", 0, NOW), end=targeting.step("on", 100, LATER) + ), +] + + +@dataclass(frozen=True) +class Case: + operation_id: str + call: Callable[[Any], Any] + minimal: Callable[[Any], Any] | None = None + forbidden: frozenset[str] = frozenset() + required: frozenset[str] = frozenset() + surface: str = "core" + name: str = "" + + +CASES = [ + # components + Case("list-components", lambda c: c.components.list()), + Case("get-component", lambda c: c.components.get("api-gateway")), + Case( + "create-component", + lambda c: c.components.create( + name="api-gateway", + version="v1.2.3", + description="Edge proxy", + previous_component_id=ID, + slug="api-gateway", + ), + minimal=lambda c: c.components.create(name="api-gateway", version="v1.2.3"), + ), + Case( + "update-component", + lambda c: c.components.update( + "api-gateway", name="api-gateway", version="v1.2.4", description="Edge proxy" + ), + minimal=lambda c: c.components.update("api-gateway", name="api-gateway", version="v1.2.4"), + forbidden=frozenset({"slug", "previousComponentId"}), + ), + Case("delete-component", lambda c: c.components.delete("api-gateway")), + # connectors + Case("list-connectors", lambda c: c.connectors.list()), + Case("get-connector", lambda c: c.connectors.get(CONNECTOR)), + Case("get-connector-state", lambda c: c.connectors.get_state(CONNECTOR)), + Case("activate-connector", lambda c: c.connectors.activate(CONNECTOR)), + Case("deactivate-connector", lambda c: c.connectors.deactivate(CONNECTOR)), + Case("get-connector-settings-schema", lambda c: c.connectors.get_settings_schema(CONNECTOR)), + Case("get-connector-settings", lambda c: c.connectors.get_settings(CONNECTOR)), + Case( + "update-connector-settings", + lambda c: c.connectors.update_settings(CONNECTOR, {"apiKey": "secret"}), + forbidden=frozenset({"connector_name"}), + ), + Case("delete-connector-settings", lambda c: c.connectors.delete_settings(CONNECTOR)), + # customers + Case("list-customers", lambda c: c.customers.list()), + Case("get-customer", lambda c: c.customers.get("acme")), + Case( + "create-customer", + lambda c: c.customers.create( + name="Acme", + slug="acme", + external_customer_id="crm-42", + domain="acme.com", + integrations={"attio": INTEGRATION}, + ), + minimal=lambda c: c.customers.create(name="Acme"), + ), + Case( + "update-customer", + lambda c: c.customers.update( + "acme", name="Acme Corp", external_customer_id="crm-42", domain="acme.com" + ), + minimal=lambda c: c.customers.update("acme", name="Acme Corp"), + forbidden=frozenset({"slug", "integrations"}), + ), + Case("delete-customer", lambda c: c.customers.delete("acme")), + Case("get-customer-integration", lambda c: c.customers.get_integration("acme", "attio")), + Case( + "create-customer-integration", + lambda c: c.customers.create_integration("acme", "attio", **INTEGRATION), + minimal=lambda c: c.customers.create_integration("acme", "attio", external_id="rec_1"), + ), + Case( + "update-customer-integration", + lambda c: c.customers.update_integration("acme", "attio", **INTEGRATION), + minimal=lambda c: c.customers.update_integration("acme", "attio", external_id="rec_1"), + ), + Case("delete-customer-integration", lambda c: c.customers.delete_integration("acme", "attio")), + # deployment zones + Case("list-deployment-zones", lambda c: c.deployment_zones.list()), + Case("get-deployment-zone-by-slug", lambda c: c.deployment_zones.get("eu-west-1")), + Case( + "create-deployment-zone", + lambda c: c.deployment_zones.create( + name="AWS eu-west-1", + type="production", + description="Primary region", + metadata={"region": "eu-west-1"}, + release_id=ID, + slug="eu-west-1", + ), + minimal=lambda c: c.deployment_zones.create(name="AWS eu-west-1", type="production"), + ), + Case( + "update-deploymentZone", + lambda c: c.deployment_zones.update( + "eu-west-1", + name="AWS eu-west-1", + type="production", + description="Primary region", + metadata={"region": "eu-west-1"}, + release_id=ID, + ), + minimal=lambda c: c.deployment_zones.update( + "eu-west-1", name="AWS eu-west-1", type="production" + ), + forbidden=frozenset({"slug"}), + ), + Case("delete-deployment-zone", lambda c: c.deployment_zones.delete("eu-west-1")), + # entitlement groups + Case("list-entitlement-groups", lambda c: c.entitlement_groups.list()), + Case("get-entitlement-group", lambda c: c.entitlement_groups.get("ai-quotas")), + Case( + "create-entitlement-group", + lambda c: c.entitlement_groups.create( + name="AI quotas", description="Everything AI", slug="ai-quotas" + ), + minimal=lambda c: c.entitlement_groups.create(name="AI quotas"), + required=frozenset({"description"}), + ), + Case( + "update-entitlement-group", + lambda c: c.entitlement_groups.update( + "ai-quotas", name="AI quotas", description="Everything AI" + ), + minimal=lambda c: c.entitlement_groups.update("ai-quotas", name="AI quotas"), + forbidden=frozenset({"slug"}), + required=frozenset({"description"}), + ), + Case("delete-entitlement-group", lambda c: c.entitlement_groups.delete("ai-quotas")), + Case( + "add-entitlement-to-group", + lambda c: c.entitlement_groups.add_entitlement("ai-quotas", "tokens"), + ), + Case( + "remove-entitlement-from-group", + lambda c: c.entitlement_groups.remove_entitlement("ai-quotas", "tokens"), + ), + Case( + "get-entitlement-group-usage", + lambda c: c.entitlement_groups.get_usage("ai-quotas", "acme-production"), + ), + # entitlements + Case("list-entitlements", lambda c: c.entitlements.list()), + Case("get-entitlement", lambda c: c.entitlements.get("seats")), + Case( + "create-entitlement", + lambda c: c.entitlements.create( + name="Seats", + type="NUMBER", + description="Named users", + slug="seats", + aggregation_method="SUM", + reset_period="MONTH", + reset_anchor="CALENDAR", + warning_threshold_percent=80, + group_slugs=["core"], + user_facing=True, + display_order=10, + icon="lucide:users", + unit_singular="seat", + unit_plural="seats", + sale_unit_singular="pack", + sale_unit_plural="packs", + sale_unit_factor=10.0, + ), + minimal=lambda c: c.entitlements.create(name="Seats"), + required=frozenset({"description"}), + ), + Case( + "update-entitlement", + lambda c: c.entitlements.update( + "seats", + name="Seats", + type="NUMBER", + description="Named users", + aggregation_method="SUM", + reset_period="MONTH", + reset_anchor="CALENDAR", + warning_threshold_percent=80, + group_slugs=["core"], + user_facing=True, + display_order=10, + icon="lucide:users", + unit_singular="seat", + unit_plural="seats", + sale_unit_singular="pack", + sale_unit_plural="packs", + sale_unit_factor=10.0, + ), + minimal=lambda c: c.entitlements.update("seats", name="Seats"), + forbidden=frozenset({"slug"}), + required=frozenset({"description"}), + ), + Case("delete-entitlement", lambda c: c.entitlements.delete("seats")), + # feature flags + Case("get-feature-flags", lambda c: c.feature_flags.list()), + Case("get-feature-flag", lambda c: c.feature_flags.get("new-checkout")), + Case( + "create-feature-flag", + lambda c: c.feature_flags.create( + name="New checkout", + type="boolean", + variants=VARIANTS, + default_variant=targeting.split({"on": 10, "off": 90}), + targetings=RULES, + enabled=False, + description="The redesigned checkout", + metadata={"team": "growth"}, + event_name="checkout_viewed", + slug="new-checkout", + ), + minimal=lambda c: c.feature_flags.create( + name="New checkout", type="boolean", variants=VARIANTS, default_variant="off" + ), + ), + Case( + "update-feature-flag", + lambda c: c.feature_flags.update( + "new-checkout", + name="New checkout", + type="boolean", + variants=VARIANTS, + default_variant=targeting.schedule( + start=targeting.step("on", 0, NOW), end=targeting.step("on", 100, LATER) + ), + targetings=RULES, + enabled=True, + description="The redesigned checkout", + metadata={"team": "growth"}, + event_name="checkout_viewed", + ), + minimal=lambda c: c.feature_flags.update( + "new-checkout", + name="New checkout", + type="boolean", + variants=VARIANTS, + default_variant=targeting.fixed("off"), + ), + forbidden=frozenset({"slug"}), + ), + Case("delete-feature-flag", lambda c: c.feature_flags.delete("new-checkout")), + Case("get-openfeature-manifest", lambda c: c.feature_flags.manifest()), + # instances + Case("getInstances", lambda c: c.instances.list()), + Case("getInstance", lambda c: c.instances.get("acme-production")), + Case( + "createInstance", + lambda c: c.instances.create( + name="Acme production", + customer_id=ID, + license_id=ID, + start_license_date=NOW, + end_license_date=LATER, + description="Dedicated cluster", + deployment_zone_id=ID, + metadata={"tier": "gold"}, + slug="acme-production", + integrations={"attio": INTEGRATION}, + ), + minimal=lambda c: c.instances.create( + name="Acme production", + customer_id=ID, + license_id=ID, + start_license_date=NOW, + end_license_date=LATER, + ), + ), + Case( + "updateInstance", + lambda c: c.instances.update( + "acme-production", + name="Acme production", + customer_id=ID, + license_id=ID, + start_license_date=NOW, + end_license_date=LATER, + description="Dedicated cluster", + deployment_zone_id=ID, + metadata={"tier": "gold"}, + slug="acme-prod", + ), + minimal=lambda c: c.instances.update( + "acme-production", + name="Acme production", + customer_id=ID, + license_id=ID, + start_license_date="2026-09-14T12:00:00Z", + end_license_date="2027-09-14T12:00:00Z", + ), + forbidden=frozenset({"integrations"}), + ), + Case( + "patchInstance", + lambda c: c.instances.update_status("acme-production", "DEGRADED"), + name="patchInstance-status", + ), + Case( + "patchInstance", + lambda c: c.instances.update_lifecycle_stage("acme-production", "AT_RISK"), + name="patchInstance-lifecycle_stage", + ), + Case("deleteInstance", lambda c: c.instances.delete("acme-production")), + Case( + "getAuditTrails", + lambda c: c.instances.list_audit_trails( + "acme-production", + event_name="INSTANCE_STATUS_CHANGED", + after=NOW, + before=LATER, + limit=10, + ), + minimal=lambda c: c.instances.list_audit_trails("acme-production"), + ), + Case("getEntitlementsUsageMetrics", lambda c: c.instances.list_usage("acme-production")), + Case("getEntitlementUsageMetrics", lambda c: c.instances.get_usage("acme-production", "seats")), + Case( + "reportEntitlementUsageMetric", + lambda c: c.instances.report_usage( + "acme-production", "seats", 3, behavior="set", metadata={"source": "billing"} + ), + minimal=lambda c: c.instances.report_usage("acme-production", "seats", 1), + ), + Case( + "get-instance-integration", + lambda c: c.instances.get_integration("acme-production", "attio"), + ), + Case( + "create-instance-integration", + lambda c: c.instances.create_integration("acme-production", "attio", **INTEGRATION), + minimal=lambda c: c.instances.create_integration( + "acme-production", "attio", external_id="rec_1" + ), + ), + Case( + "update-instance-integration", + lambda c: c.instances.update_integration("acme-production", "attio", **INTEGRATION), + minimal=lambda c: c.instances.update_integration( + "acme-production", "attio", external_id="rec_1" + ), + ), + Case( + "delete-instance-integration", + lambda c: c.instances.delete_integration("acme-production", "attio"), + ), + # integrations by external id + Case( + "get-customer-integration-by-external-id", + lambda c: c.integrations.get_customer("attio", "rec_1"), + ), + Case( + "upsert-customer-integration-by-external-id", + lambda c: c.integrations.upsert_customer( + "attio", + "rec_1", + name="Acme", + slug="acme", + domain="acme.com", + web_url="https://app.attio.com/rec_1", + error="timeout", + integration_metadata={"stage": "won"}, + ), + minimal=lambda c: c.integrations.upsert_customer("attio", "rec_1"), + ), + Case( + "get-instance-integration-by-external-id", + lambda c: c.integrations.get_instance("attio", "rec_9"), + ), + Case( + "upsert-instance-integration-by-external-id", + lambda c: c.integrations.upsert_instance( + "attio", + "rec_9", + name="Acme production", + slug="acme-production", + description="Dedicated cluster", + customer_external_id="rec_1", + license_id=ID, + deployment_zone_id=ID, + start_license_date=NOW, + end_license_date=LATER, + metadata={"tier": "gold"}, + web_url="https://app.attio.com/rec_9", + error="timeout", + integration_metadata={"stage": "live"}, + ), + minimal=lambda c: c.integrations.upsert_instance("attio", "rec_9"), + ), + # licenses + Case("get-licenses", lambda c: c.licenses.list()), + Case("get-license", lambda c: c.licenses.get("growth")), + Case( + "create-license", + lambda c: c.licenses.create( + name="Growth", + type="PAID", + description="For scaling teams", + is_default=True, + version_name="Winter 2026", + slug="growth-v2", + family_slug="growth", + family_id=ID, + lifecycle_state="PUBLISHED", + ), + minimal=lambda c: c.licenses.create(name="Growth", type="PAID"), + forbidden=frozenset({"version"}), + ), + Case( + "create-license", + lambda c: c.licenses.create( + name="Growth", type="PAID", family_slug="growth", lifecycle_state="DRAFT" + ), + forbidden=frozenset({"version", "familyId"}), + required=frozenset({"familySlug", "lifecycleState"}), + name="create-license-draft-version", + ), + Case( + "update-license", + lambda c: c.licenses.update( + "growth-v2", + name="Growth", + type="PAID", + description="For scaling teams", + is_default=True, + version_name="Winter 2026", + ), + minimal=lambda c: c.licenses.update("growth-v2", name="Growth", type="PAID"), + # a version never changes family or number, and moves state only through the + # lifecycle operations below: none of it belongs in an update + forbidden=frozenset({"slug", "version", "familySlug", "familyId", "lifecycleState"}), + ), + Case("delete-license", lambda c: c.licenses.delete("growth-v2")), + Case("publish-license", lambda c: c.licenses.publish("growth-v2")), + Case("archive-license", lambda c: c.licenses.archive("growth-v2")), + Case("unarchive-license", lambda c: c.licenses.unarchive("growth-v2")), + # license families + Case("list-license-families", lambda c: c.license_families.list()), + Case( + "get-license-family", + lambda c: c.license_families.get("growth", version=2, include_versions=True), + minimal=lambda c: c.license_families.get("growth"), + ), + Case("get-license-entitlements", lambda c: c.licenses.list_entitlements("growth")), + Case("get-license-entitlement", lambda c: c.licenses.get_entitlement("growth", "seats")), + Case( + "associate-entitlement-with-license", + lambda c: c.licenses.associate_entitlement("growth", "seats", 50, overage_percent=10), + minimal=lambda c: c.licenses.associate_entitlement("growth", "sso", True), + required=frozenset({"entitlementSlug", "value"}), + ), + Case( + "update-license-entitlement", + lambda c: c.licenses.update_entitlement( + "growth", "branding", {"logo": True}, overage_percent=0 + ), + minimal=lambda c: c.licenses.update_entitlement("growth", "seats", -1), + forbidden=frozenset({"entitlementSlug"}), + ), + Case("delete-license-entitlement", lambda c: c.licenses.delete_entitlement("growth", "seats")), + # metadata fields + Case("list-metadata-fields", lambda c: c.metadata_fields.list("INSTANCE")), + Case( + "create-metadata-field", + lambda c: c.metadata_fields.create( + resource_type="INSTANCE", + key="region", + label="Region", + json_schema={"type": "string", "enum": ["eu", "us"]}, + display_order=2, + ), + minimal=lambda c: c.metadata_fields.create( + resource_type="INSTANCE", key="region", label="Region", json_schema={"type": "string"} + ), + ), + Case( + "update-metadata-field", + lambda c: c.metadata_fields.update( + ID, + resource_type="INSTANCE", + key="region", + label="Hosting region", + json_schema={"type": "string"}, + ), + forbidden=frozenset({"displayOrder"}), + ), + Case("archive-metadata-field", lambda c: c.metadata_fields.archive(ID)), + Case("unarchive-metadata-field", lambda c: c.metadata_fields.unarchive(ID)), + Case( + "dry-run-metadata-field", + lambda c: c.metadata_fields.dry_run(ID, {"type": "string", "enum": ["eu"]}), + ), + Case("reorder-metadata-fields", lambda c: c.metadata_fields.reorder([ID, SAMPLE_ID])), + # releases + Case("list-releases", lambda c: c.releases.list()), + Case("get-release-by-slug", lambda c: c.releases.get("v1-4-0")), + Case( + "create-release", + lambda c: c.releases.create( + version="v1.4.0", description="Faster sync", component_ids=[ID], slug="v1-4-0" + ), + minimal=lambda c: c.releases.create(version="v1.4.0"), + ), + Case("delete-release", lambda c: c.releases.delete("v1-4-0")), + # service accounts + Case("get-service-accounts", lambda c: c.service_accounts.list()), + Case("get-service-account", lambda c: c.service_accounts.get("billing-sync")), + Case( + "create-service-account", + lambda c: c.service_accounts.create(name="Billing sync", slug="billing-sync"), + minimal=lambda c: c.service_accounts.create(name="Billing sync"), + ), + Case( + "update-service-account", + lambda c: c.service_accounts.update("billing-sync", name="Billing sync"), + forbidden=frozenset({"slug"}), + ), + Case("get-service-account-tokens", lambda c: c.service_accounts.list_tokens("billing-sync")), + Case( + "create-service-account-token", + lambda c: c.service_accounts.create_token( + "billing-sync", + name="ci", + scopes=["read:customers", "write:instances"], + slug="ci", + expires_at=LATER, + ), + minimal=lambda c: c.service_accounts.create_token("billing-sync", name="ci"), + required=frozenset({"scopes"}), + ), + Case( + "delete-service-account-token", + lambda c: c.service_accounts.delete_token("billing-sync", "ci"), + ), + # platform + Case("get-platform-me", lambda p: p.me(), surface="platform"), + Case( + "register-connector", + lambda p: p.connectors.register( + name=CONNECTOR, + version="1.0.0", + settings_schema={"type": "object", "properties": {"apiKey": {"writeOnly": True}}}, + entitlement_slug="crm", + ), + minimal=lambda p: p.connectors.register(name=CONNECTOR, version="1.0.0"), + required=frozenset({"settings_schema"}), + surface="platform", + ), + Case( + "ensure-organization", + lambda p: p.organizations.ensure(external_id="org_2abcDEF", name="Acme"), + minimal=lambda p: p.organizations.ensure(external_id="org_2abcDEF"), + surface="platform", + ), + Case("get-organization", lambda p: p.organizations.get(ORG_ID), surface="platform"), + Case("delete-organization", lambda p: p.organizations.delete(ORG_ID), surface="platform"), + Case( + "delete-membership", + lambda p: p.organizations.delete_membership(ORG_ID, USER_ID), + surface="platform", + ), + Case( + "mint-organization-token", + lambda p: p.tokens.mint( + ORG_ID, name="billing-sync", scopes=["write:instances"], ttl=timedelta(hours=24) + ), + minimal=lambda p: p.tokens.mint(ORG_ID, name="billing-sync"), + surface="platform", + ), + Case("list-organization-tokens", lambda p: p.tokens.list(ORG_ID), surface="platform"), + Case( + "revoke-organization-token", + lambda p: p.tokens.revoke(ORG_ID, "system-kaiten-2c8293"), + surface="platform", + ), + Case("delete-user", lambda p: p.users.delete(USER_ID), surface="platform"), +] + + +def _invocations() -> list[Any]: + params = [] + for case in CASES: + label = case.name or case.operation_id + params.append(pytest.param(case, case.call, id=label)) + if case.minimal is not None: + params.append(pytest.param(case, case.minimal, id=f"{label}-minimal")) + return params + + +def check(case: Case, recorder: Recorder) -> None: + assert recorder, "the method sent no request" + for request in recorder: + operation = find_operation(case.surface, request.method, request.url.path) + assert operation.operation_id == case.operation_id + assert set(request.url.params.keys()) <= operation.query_parameters + assert request.headers["authorization"].startswith("Bearer ") + + schema = operation.request_schema + if schema is None: + assert not request.content, f"{case.operation_id} takes no body" + continue + payload = body(request) + assert write_errors(case.surface, schema, payload) == [] + assert not case.forbidden & payload.keys(), f"sent {case.forbidden & payload.keys()}" + assert case.required <= payload.keys(), f"missing {case.required - payload.keys()}" + + +@pytest.mark.parametrize(("case", "call"), _invocations()) +def test_sync_client_speaks_the_contract( + case: Case, + call: Callable[[Any], Any], + make_client: Callable[..., Any], + make_platform_client: Callable[..., Any], +) -> None: + factory = make_platform_client if case.surface == "platform" else make_client + client, recorder = factory(contract_api(case.surface)) + call(client) + check(case, recorder) + + +@pytest.mark.parametrize(("case", "call"), _invocations()) +def test_async_client_speaks_the_contract( + case: Case, + call: Callable[[Any], Any], + make_async_client: Callable[..., Any], + make_async_platform_client: Callable[..., Any], +) -> None: + factory = make_async_platform_client if case.surface == "platform" else make_async_client + + async def scenario() -> Recorder: + client, recorder = factory(contract_api(case.surface)) + async with client: + await call(client) + return recorder + + check(case, asyncio.run(scenario())) + + +def test_every_mapped_operation_has_a_contract_case() -> None: + from contract import coverage + + covered = {case.operation_id for case in CASES} + for surface in ("core", "platform"): + mapped = set(coverage()[surface]["operations"]) + assert mapped - covered == set(), f"{surface} operations without a contract case" diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..cbc29f7 --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,114 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The clients wrap the whole contract -- and nothing but the contract. + +``openapi/coverage.yaml`` maps every operation to the method(s) that call it. These tests fail +when a contract sync adds an operation nobody wrapped, when a mapped method goes missing, when +a client grows a public method nothing maps, or when the sync and async clients disagree on a +signature. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Iterator +from typing import Any + +import pytest +from contract import coverage, operations +from helpers import BASE_URL, PLATFORM_URL + +from kaitencloud import ( + AsyncKaitenClient, + AsyncKaitenPlatformClient, + KaitenClient, + KaitenPlatformClient, +) + +SURFACES = ["core", "platform"] +LIFECYCLE = {"close", "with_options"} + + +@pytest.fixture +def clients(request: pytest.FixtureRequest) -> Iterator[tuple[Any, Any]]: + if request.param == "core": + pair: tuple[Any, Any] = ( + KaitenClient(token="ksh_x", base_url=BASE_URL), + AsyncKaitenClient(token="ksh_x", base_url=BASE_URL), + ) + else: + pair = ( + KaitenPlatformClient(token="ksm_x", base_url=PLATFORM_URL), + AsyncKaitenPlatformClient(token="ksm_x", base_url=PLATFORM_URL), + ) + yield pair + pair[0].close() + + +def mapped_methods(surface: str) -> set[str]: + methods: set[str] = set() + for target in coverage()[surface]["operations"].values(): + methods.update([target] if isinstance(target, str) else target) + return methods + + +def resolve(client: Any, dotted: str) -> Any: + target = client + for part in dotted.split("."): + target = getattr(target, part) + return target + + +def public_methods(client: Any) -> set[str]: + exposed = { + name + for name, _ in inspect.getmembers(type(client), inspect.isfunction) + if not name.startswith("_") and name not in LIFECYCLE + } + for namespace, resource in vars(client).items(): + if namespace.startswith("_"): + continue + exposed |= { + f"{namespace}.{name}" + for name, _ in inspect.getmembers(type(resource), inspect.isfunction) + if not name.startswith("_") + } + return exposed + + +@pytest.mark.parametrize("surface", SURFACES) +def test_every_operation_is_mapped_or_excluded_for_a_reason(surface: str) -> None: + section = coverage()[surface] + mapped = set(section["operations"]) + excluded = dict(section["excluded"] or {}) + published = {operation.operation_id for operation in operations(surface)} + + assert published - mapped - set(excluded) == set(), "operations nobody wrapped" + assert (mapped | set(excluded)) - published == set(), "entries for operations that are gone" + assert mapped & set(excluded) == set() + assert all(isinstance(reason, str) and reason.strip() for reason in excluded.values()) + + +@pytest.mark.parametrize("clients", SURFACES, indirect=True) +def test_every_mapped_method_exists_on_both_clients_with_one_signature( + clients: tuple[Any, Any], +) -> None: + sync_client, async_client = clients + surface = "platform" if isinstance(sync_client, KaitenPlatformClient) else "core" + + for dotted in sorted(mapped_methods(surface)): + sync_method = resolve(sync_client, dotted) + async_method = resolve(async_client, dotted) + assert not inspect.iscoroutinefunction(sync_method), dotted + assert inspect.iscoroutinefunction(async_method), dotted + assert inspect.signature(sync_method) == inspect.signature(async_method), dotted + + +@pytest.mark.parametrize("clients", SURFACES, indirect=True) +def test_no_public_method_escapes_the_map(clients: tuple[Any, Any]) -> None: + sync_client, async_client = clients + surface = "platform" if isinstance(sync_client, KaitenPlatformClient) else "core" + + assert public_methods(sync_client) == mapped_methods(surface) + assert public_methods(async_client) == mapped_methods(surface) diff --git a/tests/test_dco_workflow.py b/tests/test_dco_workflow.py new file mode 100644 index 0000000..95d35e0 --- /dev/null +++ b/tests/test_dco_workflow.py @@ -0,0 +1,117 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The DCO workflow: every commit of a pull request is signed off by its author. + +The check lives inside ``.github/workflows/dco.yml`` so the workflow is one file any repository +can copy. These tests run that very code, extracted from the workflow, rather than a copy of it. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +import yaml +from contract import ROOT + +WORKFLOW = yaml.safe_load((ROOT / ".github" / "workflows" / "dco.yml").read_text(encoding="utf-8")) + + +def _check() -> dict[str, Any]: + (step,) = [step for step in WORKFLOW["jobs"]["dco"]["steps"] if step.get("shell") == "python"] + namespace: dict[str, Any] = {"__name__": "dco_check"} # anything but __main__: no API call + exec(compile(step["run"], "dco.yml", "exec"), namespace) # the repository's own code + return namespace + + +check = _check() +JANE = {"name": "Jane Doe", "email": "jane@example.com"} + + +def commit( + message: str, + *, + author: dict[str, str] = JANE, + committer: dict[str, str] = JANE, + parents: int = 1, + account: str = "User", +) -> dict[str, Any]: + """A commit as GET /repos/{owner}/{repo}/pulls/{number}/commits lists it.""" + return { + "sha": "0123456789abcdef0123456789abcdef01234567", + "parents": [{"sha": f"p{n}"} for n in range(parents)], + "author": {"login": "jane", "type": account}, + "commit": {"message": message, "author": author, "committer": committer}, + } + + +def test_the_workflow_runs_from_main_with_read_only_access() -> None: + # `on` is YAML 1.1's spelling of true, so PyYAML reads the key as a boolean. + triggers = WORKFLOW.get("on", WORKFLOW.get(True)) + assert set(triggers) == {"pull_request_target"} # main's version judges, not the PR's + assert WORKFLOW["permissions"] == {"contents": "read", "pull-requests": "read"} + assert WORKFLOW["jobs"]["dco"]["name"] == "DCO" # the check a ruleset requires + uses = [step.get("uses", "") for step in WORKFLOW["jobs"]["dco"]["steps"]] + assert not any(uses), "the pull request's code is never checked out" + + +@pytest.mark.parametrize( + "message", + [ + "feat: a change\n\nSigned-off-by: Jane Doe ", + "feat: a change\n\nSigned-off-by: jane doe ", # case is not identity + "feat: a change\n\nSigned-off-by: Someone Else \n" + "Signed-off-by: Jane Doe ", # the author's among several is enough + "feat: a change\n\nSigned-off-by: Jane Doe \n", # trailing spaces + ], +) +def test_a_commit_signed_off_by_its_author_passes(message: str) -> None: + assert check["problem"](commit(message)) is None + + +def test_a_commit_without_a_sign_off_fails_and_says_what_was_expected() -> None: + why = check["problem"](commit("feat: a change")) + assert why is not None + assert "no Signed-off-by" in why + assert "Jane Doe " in why + + +@pytest.mark.parametrize( + "signoff", + [ + "Signed-off-by: Jane Doe ", # the right name, another address + "Signed-off-by: J. Doe ", # the right address, another name + "Signed-off-by: Someone Else ", # somebody else entirely + ], +) +def test_a_sign_off_by_anyone_but_the_author_fails(signoff: str) -> None: + why = check["problem"](commit(f"feat: a change\n\n{signoff}")) + assert why is not None + assert "not by its author" in why + + +def test_the_committer_cannot_sign_off_for_the_author() -> None: + applied = commit( + "fix: a change\n\nSigned-off-by: Max Mustermann ", + committer={"name": "Max Mustermann", "email": "max@example.com"}, + ) + assert check["problem"](applied) is not None + + +def test_a_sign_off_must_be_a_trailer_line_not_a_mention() -> None: + quoted = "docs: explain the DCO\n\nWrite `Signed-off-by: Jane Doe ` in yours." + assert check["problem"](commit(quoted)) is not None + + +def test_a_merge_commit_is_not_checked() -> None: + assert check["problem"](commit("Merge branch 'main'", parents=2)) is None + + +def test_a_bot_account_is_not_checked() -> None: + bump = commit( + "build(deps): bump httpx", + author={"name": "dependabot[bot]", "email": "support@github.com"}, + account="Bot", + ) + assert check["problem"](bump) is None diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..ac882ef --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,201 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Every failure becomes a typed error that keeps what the API said.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from helpers import respond + +from kaitencloud import ( + APIConnectionError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + ConflictError, + CredentialError, + InternalServerError, + KaitenError, + NotFoundError, + PaginationError, + PermissionDeniedError, + RateLimitError, + ServiceUnavailableError, + ThresholdExceededError, + UnprocessableEntityError, + WebhookPayloadError, + WebhookVerificationError, +) + + +@pytest.mark.parametrize( + ("status", "error"), + [ + (400, BadRequestError), + (401, AuthenticationError), + (403, PermissionDeniedError), + (404, NotFoundError), + (409, ConflictError), + (418, APIStatusError), + (422, UnprocessableEntityError), + (429, RateLimitError), + (500, InternalServerError), + (502, InternalServerError), + (503, ServiceUnavailableError), + ], +) +def test_each_status_raises_its_own_error( + make_client: Callable[..., Any], status: int, error: type[APIStatusError] +) -> None: + client, _ = make_client(respond(status, {"title": "Refused"}), max_retries=0) + with pytest.raises(APIStatusError) as caught: + client.customers.get("acme") + assert type(caught.value) is error + assert caught.value.status_code == status + + +def test_the_problem_document_is_kept_whole(make_client: Callable[..., Any]) -> None: + problem = { + "type": "about:blank", + "title": "Not Found", + "status": 404, + "detail": "license growth does not exist", + "instance": "/api/licenses/growth", + "code": "License.NotFound", + "errorId": "01J9ZQK3", + "errors": [{"location": "path.licenseSlug", "message": "unknown", "value": "growth"}], + } + client, _ = make_client(respond(404, problem)) + + with pytest.raises(NotFoundError) as caught: + client.licenses.get("growth") + + error = caught.value + assert (error.code, error.title, error.detail, error.error_id) == ( + "License.NotFound", + "Not Found", + "license growth does not exist", + "01J9ZQK3", + ) + assert [detail.location for detail in error.errors] == ["path.licenseSlug"] + assert error.request.url.path == "/api/licenses/growth" + assert str(error) == ( + "Kaiten API error (404 Not Found) [License.NotFound]: " + "Not Found: license growth does not exist (errorId: 01J9ZQK3)" + ) + + +def test_a_body_that_is_not_a_problem_is_reported_raw(make_client: Callable[..., Any]) -> None: + client, _ = make_client(respond(502, "502 Bad Gateway"), max_retries=0) + + with pytest.raises(InternalServerError) as caught: + client.customers.list() + + assert caught.value.problem is None + assert caught.value.body == "502 Bad Gateway" + assert str(caught.value) == "Kaiten API error (502 Bad Gateway): 502 Bad Gateway" + + +def test_an_empty_error_body_still_names_the_status(make_client: Callable[..., Any]) -> None: + client, _ = make_client(respond(403)) + with pytest.raises(PermissionDeniedError, match=r"^Kaiten API error \(403 Forbidden\)$"): + client.customers.list() + + +def test_a_refused_usage_report_is_a_threshold_exceeded_error( + make_client: Callable[..., Any], +) -> None: + problem = { + "title": "Conflict", + "status": 409, + "detail": "usage would exceed the maximum allowed usage", + "code": "ReportEntitlementUsageMetric.ThresholdExceeded", + } + client, _ = make_client(respond(409, problem)) + + with pytest.raises(ThresholdExceededError) as caught: + client.instances.report_usage("acme-production", "seats", 5) + + assert isinstance(caught.value, ConflictError) + assert caught.value.code == "ReportEntitlementUsageMetric.ThresholdExceeded" + + +def test_only_a_usage_report_reads_a_409_as_a_reached_threshold( + make_client: Callable[..., Any], +) -> None: + client, _ = make_client(respond(409, {"title": "Conflict", "detail": "slug is taken"})) + with pytest.raises(ConflictError) as caught: + client.customers.create(name="Acme", slug="acme") + assert not isinstance(caught.value, ThresholdExceededError) + + +def test_another_failure_of_a_usage_report_is_not_a_threshold( + make_client: Callable[..., Any], +) -> None: + client, _ = make_client(respond(500, {"title": "Internal Server Error"})) + with pytest.raises(InternalServerError) as caught: + client.instances.report_usage("acme-production", "seats", 5) + assert not isinstance(caught.value, ThresholdExceededError) + + +def test_a_success_that_is_not_json_is_a_validation_error(make_client: Callable[..., Any]) -> None: + client, _ = make_client(respond(200, "not json")) + with pytest.raises(APIResponseValidationError, match="not JSON"): + client.customers.get("acme") + + +def test_a_success_of_the_wrong_shape_is_a_validation_error( + make_client: Callable[..., Any], +) -> None: + client, _ = make_client(respond(200, {"id": "an id and nothing else"})) + with pytest.raises(APIResponseValidationError, match="Customer") as caught: + client.customers.get("acme") + assert caught.value.response.status_code == 200 + + +def test_a_connection_failure_is_a_connection_error(make_client: Callable[..., Any]) -> None: + def refuse(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + client, _ = make_client(refuse, max_retries=0) + with pytest.raises(APIConnectionError) as caught: + client.customers.list() + assert isinstance(caught.value.__cause__, httpx.ConnectError) + assert caught.value.request.url.path == "/api/customers" + + +def test_a_timeout_is_a_timeout_error(make_client: Callable[..., Any]) -> None: + def hang(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("timed out", request=request) + + client, _ = make_client(hang, max_retries=0) + with pytest.raises(APITimeoutError): + client.customers.list() + + +@pytest.mark.parametrize( + "error", + [ + APIConnectionError, + APIResponseValidationError, + APIStatusError, + CredentialError, + PaginationError, + ThresholdExceededError, + WebhookPayloadError, + WebhookVerificationError, + ], +) +def test_every_sdk_error_is_a_kaiten_error(error: type[Exception]) -> None: + assert issubclass(error, KaitenError) + + +def test_a_credential_error_is_also_a_value_error() -> None: + assert issubclass(CredentialError, ValueError) diff --git a/tests/test_headers.py b/tests/test_headers.py new file mode 100644 index 0000000..34f86da --- /dev/null +++ b/tests/test_headers.py @@ -0,0 +1,35 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Every Python file opens with the project's license notice, generated ones included. + +The notice is what tells a reader of a single file -- copied out of the repository, vendored, +read on a code search -- under which terms it may be used, without the LICENSE next to it. A +file that lacks it is caught here, rather than by a license scanner after a release. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from contract import ROOT + +HEADER = ["# Copyright 2026 KAITEN INC", "# SPDX-License-Identifier: Apache-2.0"] +FILES = sorted( + path + for folder in ("src/kaitencloud", "scripts", "tests", "examples") + for path in (ROOT / folder).rglob("*.py") +) + + +def test_there_are_files_to_check() -> None: + assert len(FILES) > 50 + + +@pytest.mark.parametrize("path", FILES, ids=lambda path: str(path.relative_to(ROOT))) +def test_every_python_file_opens_with_the_license_notice(path: Path) -> None: + lines = path.read_text(encoding="utf-8").splitlines() + if lines and lines[0].startswith("#!"): + lines = lines[1:] # a script's interpreter line has to come first + assert lines[:2] == HEADER, f"{path.relative_to(ROOT)} lacks the license notice" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..245e7f8 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,136 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Models are lenient where the API grows, and give the exact wire shape back.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from typing import Any + +import pytest +from contract import ROOT, document, sample + +import kaitencloud.types as types +from kaitencloud import KaitenModel +from kaitencloud.types import ( + BasicVariant, + Customer, + EntitlementUsage, + FeatureFlag, + Instance, + NumberEntitlementValue, + RolloutPercentageTargeting, +) + +sys.path.insert(0, str(ROOT / "scripts")) +from generate_models import CLASS_NAMES + +PLATFORM_ONLY = {"Organization", "PlatformCredential"} +SCHEMA_NAMES = {python: schema for schema, python in CLASS_NAMES.items()} +MODELS = sorted( + name + for name in types.__all__ + if isinstance(getattr(types, name), type) and issubclass(getattr(types, name), KaitenModel) +) + +CUSTOMER = { + "id": "c1", + "name": "Acme", + "externalCustomerId": None, + "createdAt": "2026-09-14T12:00:00Z", + "updatedAt": "2026-09-14T12:00:00Z", + "createdBy": {"id": "u1", "name": "Ada"}, + "updatedBy": {"id": "u1"}, +} + + +@pytest.mark.parametrize("name", MODELS) +def test_every_model_round_trips_a_payload_shaped_like_its_schema(name: str) -> None: + model: type[KaitenModel] = getattr(types, name) + surface = "platform" if name in PLATFORM_ONLY else "core" + schema = {"$ref": f"#/components/schemas/{SCHEMA_NAMES.get(name, name)}"} + assert SCHEMA_NAMES.get(name, name) in document(surface)["components"]["schemas"] + + full: dict[str, Any] = sample(schema, surface) + assert model.model_validate(full).to_dict() == full + model.model_validate(sample(schema, surface, full=False)) + + +def test_attributes_are_snake_case_and_to_dict_gives_wire_names_back() -> None: + customer = Customer.model_validate(CUSTOMER) + assert customer.external_customer_id is None + assert customer.created_at == datetime(2026, 9, 14, 12, tzinfo=timezone.utc) + assert customer.created_by.name == "Ada" + assert customer.to_dict() == CUSTOMER + assert '"createdAt":"2026-09-14T12:00:00Z"' in customer.to_json() + + +def test_a_model_can_be_built_from_attribute_names() -> None: + customer = Customer( + id="c1", + name="Acme", + created_at=datetime(2026, 9, 14, 12, tzinfo=timezone.utc), + updated_at=datetime(2026, 9, 14, 12, tzinfo=timezone.utc), + created_by={"id": "u1"}, + updated_by={"id": "u1"}, + ) + assert customer.to_dict()["createdAt"] == "2026-09-14T12:00:00Z" + + +def test_an_enum_value_newer_than_the_sdk_parses_as_a_string() -> None: + instance = Instance.model_validate( + { + **sample({"$ref": "#/components/schemas/Instance"}, "core"), + "status": "HIBERNATING", + } + ) + assert instance.status == "HIBERNATING" + + +def test_a_field_newer_than_the_sdk_is_kept_rather_than_refused() -> None: + customer = Customer.model_validate({**CUSTOMER, "healthScore": 92}) + assert customer.model_extra == {"healthScore": 92} + assert customer.to_dict()["healthScore"] == 92 + + +def test_discriminated_values_parse_into_their_own_class() -> None: + usage = EntitlementUsage.model_validate( + { + "entitlementId": "e1", + "entitlementSlug": "seats", + "licenseId": "l1", + "licenseSlug": "growth", + "value": {"type": "number", "value": 7, "event_count": 3}, + "limit": {"type": "number", "value": 10}, + } + ) + assert isinstance(usage.value, NumberEntitlementValue) + assert (usage.value.value, usage.value.event_count) == (7, 3) + assert isinstance(usage.limit, NumberEntitlementValue) + assert usage.current_period_start is None + + +def test_a_flag_parses_its_default_variant_and_rules_by_type() -> None: + flag = FeatureFlag.model_validate( + { + **sample({"$ref": "#/components/schemas/FeatureFlag"}, "core"), + "default_variant": {"type": "basic", "value": "off"}, + "targetings": [ + { + "type": "rollout_percentage", + "name": "Rollout", + "rule": "true", + "distribution": {"on": 1}, + } + ], + } + ) + assert isinstance(flag.default_variant, BasicVariant) + assert flag.targetings is not None + assert isinstance(flag.targetings[0], RolloutPercentageTargeting) + + +def test_types_exports_the_parameter_shapes_too() -> None: + assert {"IntegrationParam", "VariantParam", "TargetingParam"} <= set(types.__all__) diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..7494bff --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,157 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""List methods walk every page, and refuse to hand back a list they know is partial. + +Kaiten's default page is 50 rows. A list method that returned one page would keep working +right up to the day the 51st row was created, and then silently omit it. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from contract import sample +from helpers import Handler, Recorder, respond + +from kaitencloud import APIResponseValidationError, AuthenticationError, PaginationError + +GROUP = {"id": "g0", "name": "AI quotas", "description": None, "slug": "ai-quotas"} + + +def groups(count: int) -> list[dict[str, Any]]: + return [{**GROUP, "id": f"g{index}", "slug": f"group-{index}"} for index in range(count)] + + +def rows_of(schema: str, count: int) -> list[dict[str, Any]]: + row = sample({"$ref": f"#/components/schemas/{schema}"}, "core") + return [{**row, "id": f"row-{index}"} for index in range(count)] + + +def paged(rows: list[dict[str, Any]]) -> Handler: + """A fake list endpoint that pages the way Kaiten does, honouring limit and cursor.""" + + def handler(request: httpx.Request) -> httpx.Response: + limit = int(request.url.params["limit"]) + start = int(request.url.params.get("cursor", "0")) + end = min(start + limit, len(rows)) + page: dict[str, Any] = {"items": rows[start:end], "hasMore": end < len(rows)} + if end < len(rows): + page["nextCursor"] = str(end) + return httpx.Response(200, json=page) + + return handler + + +def test_a_list_walks_every_page_at_the_largest_page_size(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(paged(groups(451))) + + result = client.entitlement_groups.list() + + assert [group.slug for group in result] == [f"group-{index}" for index in range(451)] + assert [request.url.params["limit"] for request in recorder] == ["200", "200", "200"] + assert [request.url.params.get("cursor") for request in recorder] == [None, "200", "400"] + + +def test_an_empty_collection_is_an_empty_list(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(200, {"items": [], "hasMore": False})) + assert client.entitlement_groups.list() == [] + assert len(recorder) == 1 + + +def test_an_endpoint_filter_survives_every_page(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(paged(rows_of("MetadataField", 300))) + + fields = client.metadata_fields.list("DEPLOYMENT_ZONE") + + assert len(fields) == 300 + assert len(recorder) == 2 + assert {request.url.params["resourceType"] for request in recorder} == {"DEPLOYMENT_ZONE"} + + +def test_a_limit_is_a_ceiling_and_no_page_asks_for_more_than_is_left( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(paged(rows_of("AuditTrail", 1000))) + + entries = client.instances.list_audit_trails("acme-production", limit=250) + + assert len(entries) == 250 + assert [request.url.params["limit"] for request in recorder] == ["200", "50"] + + +def test_a_limit_below_one_is_refused(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(paged([])) + with pytest.raises(ValueError, match="limit"): + client.instances.list_audit_trails("acme-production", limit=0) + assert not recorder + + +@pytest.mark.parametrize( + "page", + [ + {"items": [GROUP], "hasMore": True}, + {"items": [GROUP], "hasMore": True, "nextCursor": ""}, + ], + ids=["no cursor", "empty cursor"], +) +def test_more_rows_without_a_cursor_is_an_error_not_a_short_list( + make_client: Callable[..., Any], page: dict[str, Any] +) -> None: + client, _ = make_client(respond(200, page)) + with pytest.raises(PaginationError): + client.entitlement_groups.list() + + +def test_a_cursor_that_does_not_move_is_an_error_not_a_loop( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client( + respond(200, {"items": [GROUP], "hasMore": True, "nextCursor": "stuck"}) + ) + with pytest.raises(PaginationError): + client.entitlement_groups.list() + assert len(recorder) == 2 + + +def test_a_deployment_older_than_pagination_answers_a_bare_array( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(respond(200, groups(3))) + assert len(client.entitlement_groups.list()) == 3 + assert len(recorder) == 1 + + +def test_an_error_during_the_walk_keeps_its_status(make_client: Callable[..., Any]) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if "cursor" in request.url.params: + return httpx.Response(401, json={"title": "Unauthorized", "detail": "token revoked"}) + return httpx.Response(200, json={"items": [GROUP], "hasMore": True, "nextCursor": "1"}) + + client, _ = make_client(handler) + with pytest.raises(AuthenticationError) as caught: + client.entitlement_groups.list() + assert caught.value.status_code == 401 + assert caught.value.detail == "token revoked" + + +def test_an_answer_that_is_not_a_page_is_a_validation_error( + make_client: Callable[..., Any], +) -> None: + client, _ = make_client(respond(200, {"data": []})) + with pytest.raises(APIResponseValidationError, match="page"): + client.entitlement_groups.list() + + +def test_the_async_client_walks_the_same_way(make_async_client: Callable[..., Any]) -> None: + async def scenario() -> tuple[int, Recorder]: + client, recorder = make_async_client(paged(groups(451))) + return len(await client.entitlement_groups.list()), recorder + + count, recorder = asyncio.run(scenario()) + assert count == 451 + assert len(recorder) == 3 diff --git a/tests/test_playbook.py b/tests/test_playbook.py new file mode 100644 index 0000000..4876178 --- /dev/null +++ b/tests/test_playbook.py @@ -0,0 +1,155 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The example playbook runs, end to end, against a fake API built from the contract. + +``examples/playbook.py`` is a script people run against their own deployment, so it is kept +honest the same way the README is: every step is executed here against the contract's own +shapes, with nothing on the network. A method renamed in the SDK, or an argument the contract +no longer accepts, fails here rather than halfway through somebody's walkthrough. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import Any + +import httpx +import pytest +import requests +from contract import ROOT, contract_api, find_operation, sample +from helpers import BASE_URL, ORG_ID, PLATFORM_URL, Recorder, recording + +from kaitencloud import KaitenClient, KaitenPlatformClient + + +def _load() -> Any: + path = ROOT / "examples" / "playbook.py" + spec = importlib.util.spec_from_file_location("playbook", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["playbook"] = module + spec.loader.exec_module(module) + return module + + +playbook = _load() + + +@pytest.fixture(autouse=True) +def ofrep(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, Any]]: + """The OpenFeature provider's own HTTP calls, answered from the contract. + + The playbook evaluates flags through ``openfeature-provider-ofrep``, which sends them with + ``requests`` rather than the SDK's httpx client -- so the fake transport the rest of these + tests use never sees them. This answers them the way the contract does, and records where + the provider went, which is what pins the base URL convention down. + """ + calls: list[tuple[str, Any]] = [] + + def post(_session: requests.Session, url: str, **kwargs: Any) -> requests.Response: + calls.append((url, kwargs)) + _, schema = find_operation("core", "POST", "/api/ofrep/v1/evaluate/flags/a-flag").success() + body = sample(schema, "core") + body["value"] = True # OFREP types `value` as any: the flag's own type decides + response = requests.Response() + response.status_code = 200 + response.headers["content-type"] = "application/json" + response._content = json.dumps(body).encode() + return response + + monkeypatch.setattr(requests.Session, "post", post) + return calls + + +@pytest.fixture +def book(tmp_path: Path) -> Any: + """A playbook wired to fake APIs that answer the way the contract says.""" + + def core(token: str = "ksh_playbook") -> KaitenClient: + transport = recording(contract_api("core"), Recorder()) + return KaitenClient( + token=token, base_url=BASE_URL, http_client=httpx.Client(transport=transport) + ) + + platform = KaitenPlatformClient( + token="ksm_playbook", + base_url=PLATFORM_URL, + http_client=httpx.Client(transport=recording(contract_api("platform"), Recorder())), + ) + return playbook.Playbook( + client=core(), + state=playbook.State(tmp_path / "state.json"), + platform=platform, + platform_org=ORG_ID, + client_factory=core, + token="ksh_playbook", + ) + + +@pytest.mark.parametrize("step", playbook.STEPS, ids=lambda step: step.name) +def test_every_step_runs(step: Any, book: Any, capsys: pytest.CaptureFixture[str]) -> None: + step.run(book) + assert capsys.readouterr().out, f"{step.name} printed nothing" + + +def test_the_steps_run_in_order_and_leave_nothing_behind( + book: Any, capsys: pytest.CaptureFixture[str] +) -> None: + for step in playbook.STEPS: + step.run(book) + + output = capsys.readouterr().out + assert "created" in output + assert "deleted" in output # the cleanup step ran + assert not book.state.path.exists() # and cleared the state file + + +def test_flags_are_evaluated_through_openfeature( + book: Any, ofrep: list[tuple[str, Any]], capsys: pytest.CaptureFixture[str] +) -> None: + """The SDK defines the flag; the OpenFeature provider is what reads it back.""" + step = next(step for step in playbook.STEPS if step.name == "flags") + step.run(book) + + assert ofrep, "the playbook never evaluated a flag through OpenFeature" + for url, kwargs in ofrep: + assert "/api/ofrep/v1/evaluate/flags/" in url # the provider appends this to base_url + assert kwargs["headers"] == {"Authorization": "Bearer ksh_playbook"} + assert kwargs["json"]["context"]["targetingKey"].endswith("-acme") + + output = capsys.readouterr().out + assert "OFREPProvider" in output + assert "client.flags" not in output # the SDK has no evaluation method, on purpose + + +def test_the_steps_are_named_once_and_start_and_end_where_expected() -> None: + names = [step.name for step in playbook.STEPS] + assert len(names) == len(set(names)) + assert names[0] == "connect" + assert names[-1] == "cleanup" + + +def test_listing_the_steps_needs_no_credential(capsys: pytest.CaptureFixture[str]) -> None: + assert playbook.main(["--list"]) == 0 + listed = capsys.readouterr().out + assert all(step.name in listed for step in playbook.STEPS) + + +@pytest.mark.parametrize("argv", [["nope"], ["--from", "nope"]]) +def test_an_unknown_step_is_refused(argv: list[str]) -> None: + with pytest.raises(SystemExit, match="nope"): + playbook.main(argv) + + +def test_a_selection_is_honoured() -> None: + selected = playbook.chosen(playbook.parse(["catalog", "usage"])) + assert [step.name for step in selected] == ["catalog", "usage"] + + resumed = playbook.chosen(playbook.parse(["--from", "flags"])) + assert next(step.name for step in resumed) == "flags" + assert "cleanup" not in [step.name for step in resumed] # never by accident diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..c45506d --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,100 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The README's examples are valid Python, everything they call exists, and its links resolve. + +A README is the first code a user copies. These tests keep it from drifting away from the SDK: +every ``python`` block must parse, every name it imports from ``kaitencloud`` must exist, and +every ``client.x.y()`` / ``platform.x.y()`` call must name a real method. The links of both +READMEs -- this one and ``examples/README.md``, which it hands the playbook over to -- must +point at a file that exists and a heading that is really there. +""" + +from __future__ import annotations + +import ast +import importlib +import re +from collections.abc import Iterator +from pathlib import Path + +import pytest +from helpers import BASE_URL, PLATFORM_URL + +from kaitencloud import KaitenClient, KaitenPlatformClient, targeting, usage + +ROOT = Path(__file__).resolve().parent.parent +README = (ROOT / "README.md").read_text(encoding="utf-8") +MARKDOWN = { + path: path.read_text(encoding="utf-8") + for path in (ROOT / "README.md", ROOT / "examples" / "README.md", ROOT / "CONTRIBUTING.md") +} +LINK = re.compile(r"\[[^\]]+\]\((?!https?:)([^)#]*)(#[^)]+)?\)") +EXAMPLES = re.findall(r"```python\n(.*?)```", README, flags=re.DOTALL) +CLIENT_CALL = re.compile(r"\b(client|platform)\.(\w+)(?:\.(\w+))?\(") +HELPER_CALL = re.compile(r"\b(targeting|usage)\.(\w+)\(") + + +@pytest.fixture +def clients() -> Iterator[dict[str, object]]: + core = KaitenClient(token="ksh_readme", base_url=BASE_URL) + platform = KaitenPlatformClient(token="ksm_readme", base_url=PLATFORM_URL) + yield {"client": core, "platform": platform} + core.close() + platform.close() + + +def test_the_readme_shows_real_code() -> None: + assert len(EXAMPLES) >= 10 + + +@pytest.mark.parametrize( + "example", EXAMPLES, ids=[f"example-{n + 1}" for n in range(len(EXAMPLES))] +) +def test_every_example_is_valid_python(example: str) -> None: + ast.parse(example) + + +def test_every_import_from_the_sdk_exists() -> None: + for example in EXAMPLES: + for node in ast.walk(ast.parse(example)): + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("kaitencloud"): + module = importlib.import_module(str(node.module)) + for alias in node.names: + assert hasattr(module, alias.name), f"from {node.module} import {alias.name}" + + +def test_every_client_call_names_a_real_method(clients: dict[str, object]) -> None: + calls = [call for example in EXAMPLES for call in CLIENT_CALL.findall(example)] + assert calls + for owner, attribute, method in calls: + target = getattr(clients[owner], attribute) + if method: + target = getattr(target, method) + assert callable(target), f"{owner}.{attribute}.{method}" + + +def test_every_helper_call_names_a_real_function() -> None: + modules = {"targeting": targeting, "usage": usage} + for example in EXAMPLES: + for module, function in HELPER_CALL.findall(example): + assert callable(getattr(modules[module], function)), f"{module}.{function}" + + +def anchors(text: str) -> set[str]: + """The fragments GitHub derives from a document's headings.""" + found = set() + for heading in re.findall(r"^#+ +(.+?)\s*$", text, flags=re.MULTILINE): + slug = re.sub(r"[^\w -]", "", heading.replace("`", "").lower()).strip() + found.add("#" + slug.replace(" ", "-")) + return found + + +@pytest.mark.parametrize("source", MARKDOWN, ids=lambda path: str(path.relative_to(ROOT))) +def test_every_link_points_at_something_that_exists(source: Path) -> None: + for target, fragment in LINK.findall(MARKDOWN[source]): + destination = (source.parent / target).resolve() if target else source + assert destination.exists(), f"{source.name}: {target} does not exist" + if fragment and destination.suffix == ".md": + text = MARKDOWN.get(destination) or destination.read_text(encoding="utf-8") + assert fragment in anchors(text), f"{source.name}: {target}{fragment} has no heading" diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..132b80d --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,331 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""What the contract table cannot see: value encoding, escaping, defaults and early refusals.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +from contract import sample +from helpers import ORG_ID, body, respond + +from kaitencloud.types import ManifestFlag, NumberEntitlementValue + +CUSTOMER = { + "id": "c1", + "name": "Acme", + "slug": "acme", + "createdAt": "2026-09-14T12:00:00Z", + "updatedAt": "2026-09-14T12:00:00Z", + "createdBy": {"id": "u1"}, + "updatedBy": {"id": "u1"}, +} +TOKEN = { + "name": "billing-sync", + "slug": "system-kaiten-2c8293", + "token": "ksh_plaintext", + "scopes": ["write:instances"], + "createdAt": "2026-09-14T12:00:00Z", + "createdBy": {"id": "u1"}, +} +INSTANCE_WRITE = { + "name": "Acme production", + "customer_id": "c1", + "license_id": "l1", + "start_license_date": datetime(2026, 9, 14, tzinfo=timezone.utc), + "end_license_date": datetime(2027, 9, 14, tzinfo=timezone.utc), +} +INSTANCE = sample({"$ref": "#/components/schemas/Instance"}, "core") +FAMILY = sample({"$ref": "#/components/schemas/LicenseFamilyView"}, "core") +USAGE = sample({"$ref": "#/components/schemas/EntitlementUsage"}, "core") + + +def test_a_slug_is_escaped_into_a_single_path_segment(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(200, CUSTOMER)) + client.customers.get("acme/../admin") + assert recorder.only.url.raw_path == b"/api/customers/acme%2F..%2Fadmin" + + +@pytest.mark.parametrize("slug", ["", " "]) +def test_an_empty_slug_is_refused_before_anything_is_sent( + make_client: Callable[..., Any], slug: str +) -> None: + client, recorder = make_client(respond(204)) + with pytest.raises(ValueError, match="non-empty"): + client.customers.delete(slug) + assert not recorder + + +@pytest.mark.parametrize( + ("value", "wire"), + [ + (True, {"type": "boolean", "value": True}), + (False, {"type": "boolean", "value": False}), + (50, {"type": "number", "value": 50}), + (2.5, {"type": "number", "value": 2.5}), + (-1, {"type": "number", "value": -1}), + ({"theme": "dark"}, {"type": "object", "value": {"theme": "dark"}}), + ], +) +def test_an_entitlement_value_is_wrapped_according_to_its_type( + make_client: Callable[..., Any], value: Any, wire: dict[str, Any] +) -> None: + client, recorder = make_client(respond(204)) + client.licenses.update_entitlement("growth", "seats", value) + assert body(recorder.only) == {"value": wire} + + +def test_an_entitlement_value_model_is_sent_without_its_server_fields( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(respond(204)) + value = NumberEntitlementValue(type="number", value=10, event_count=3) + client.licenses.associate_entitlement("growth", "seats", value, overage_percent=0) + assert body(recorder.only) == { + "entitlementSlug": "seats", + "value": {"type": "number", "value": 10.0}, + "limitCapExceededOveragePercent": 0, + } + + +def test_an_entitlement_value_of_another_type_is_refused(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + with pytest.raises(TypeError, match="entitlement value"): + client.licenses.update_entitlement("growth", "seats", "fifty") # type: ignore[arg-type] + assert not recorder + + +def test_datetimes_are_sent_as_utc_rfc3339(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(201, INSTANCE)) + paris = timezone(timedelta(hours=2)) + + client.instances.create( + **{ + **INSTANCE_WRITE, + "start_license_date": datetime(2026, 9, 14, 14, 0, tzinfo=paris), + "end_license_date": "2027-09-14T00:00:00Z", + } + ) + + payload = body(recorder.only) + assert payload["startLicenseDate"] == "2026-09-14T12:00:00Z" + assert payload["endLicenseDate"] == "2027-09-14T00:00:00Z" + + +def test_a_naive_datetime_is_refused_rather_than_guessed(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + with pytest.raises(ValueError, match="timezone"): + client.instances.update( + "acme-production", **{**INSTANCE_WRITE, "start_license_date": datetime(2026, 9, 14)} + ) + assert not recorder + + +def test_an_instance_update_leaves_out_what_it_keeps(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + client.instances.update("acme-production", **INSTANCE_WRITE) + assert body(recorder.only) == { + "name": "Acme production", + "description": "", + "customerId": "c1", + "licenseId": "l1", + "startLicenseDate": "2026-09-14T00:00:00Z", + "endLicenseDate": "2027-09-14T00:00:00Z", + } + + +def test_an_instance_update_with_a_slug_renames_it(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + client.instances.update("acme-production", **INSTANCE_WRITE, slug="acme-prod") + assert body(recorder.only)["slug"] == "acme-prod" + + +def test_a_required_nullable_description_is_sent_as_null(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + client.entitlements.update("seats", name="Seats") + assert body(recorder.only) == {"name": "Seats", "description": None} + + +def test_a_token_without_scopes_inherits_them_by_sending_null( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(respond(201, TOKEN)) + client.service_accounts.create_token("billing-sync", name="ci") + assert body(recorder.only) == {"name": "ci", "scopes": None} + + +def test_a_feature_flag_needs_only_the_essentials(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + + client.feature_flags.update( + "new-checkout", + name="New checkout", + type="boolean", + variants=[{"name": "on", "value": True}, {"name": "off", "value": False}], + default_variant="off", + ) + + assert body(recorder.only) == { + "name": "New checkout", + "type": "boolean", + "variants": [ + {"name": "on", "description": "", "value": True}, + {"name": "off", "description": "", "value": False}, + ], + "default_variant": {"type": "basic", "value": "off"}, + "targetings": [], + "enabled": True, + "description": None, + "metadata": {}, + "event_name": "", + } + + +def test_usage_defaults_to_append(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(200, USAGE)) + client.instances.report_usage("acme-production", "seats", 3) + assert body(recorder.only) == {"value": {"type": "number", "value": 3}, "behavior": "append"} + + +def test_usage_must_be_a_number(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(204)) + with pytest.raises(TypeError, match="number"): + client.instances.report_usage("acme-production", "seats", True) + assert not recorder + + +def test_audit_trail_filters_travel_as_query_parameters(make_client: Callable[..., Any]) -> None: + client, recorder = make_client(respond(200, {"items": [], "hasMore": False})) + client.instances.list_audit_trails( + "acme-production", + event_name="INSTANCE_STATUS_CHANGED", + after=datetime(2026, 9, 1, tzinfo=timezone.utc), + before="2026-09-14T00:00:00Z", + limit=20, + ) + assert dict(recorder.only.url.params) == { + "event_name": "INSTANCE_STATUS_CHANGED", + "after": "2026-09-01T00:00:00Z", + "before": "2026-09-14T00:00:00Z", + "limit": "20", + } + + +def test_a_family_resolves_to_its_current_version_when_asked_nothing( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(respond(200, FAMILY)) + client.license_families.get("growth") + assert recorder.only.url.path.endswith("/license-families/growth") + assert not recorder.only.url.params # no version pinned, no extra representation + + +def test_a_pinned_version_and_the_history_travel_as_query_parameters( + make_client: Callable[..., Any], +) -> None: + client, recorder = make_client(respond(200, FAMILY)) + client.license_families.get("growth", version=2, include_versions=True) + assert dict(recorder.only.url.params) == {"version": "2", "include": "versions"} + + +def test_a_new_version_names_its_family_and_starts_as_a_draft_when_asked( + make_client: Callable[..., Any], +) -> None: + license = sample({"$ref": "#/components/schemas/License"}, "core") + client, recorder = make_client(respond(201, license)) + client.licenses.create( + name="Growth", type="PAID", family_slug="growth", lifecycle_state="DRAFT" + ) + sent = body(recorder.only) + assert sent["familySlug"] == "growth" + assert sent["lifecycleState"] == "DRAFT" + assert "familyId" not in sent # unset, rather than sent empty + + +def test_a_new_license_opens_a_family_by_naming_none(make_client: Callable[..., Any]) -> None: + license = sample({"$ref": "#/components/schemas/License"}, "core") + client, recorder = make_client(respond(201, license)) + client.licenses.create(name="Growth", type="PAID") + assert not {"familySlug", "familyId", "lifecycleState"} & body(recorder.only).keys() + + +@pytest.mark.parametrize("transition", ["publish", "archive", "unarchive"]) +def test_a_lifecycle_transition_is_a_bodiless_post_that_returns_the_version( + make_client: Callable[..., Any], transition: str +) -> None: + license = sample({"$ref": "#/components/schemas/License"}, "core") + client, recorder = make_client(respond(200, license)) + moved = getattr(client.licenses, transition)("growth-v2") + assert moved.id == license["id"] + assert recorder.only.method == "POST" + assert recorder.only.url.path.endswith(f"/licenses/growth-v2/{transition}") + assert not recorder.only.content + + +@pytest.mark.parametrize("payload", [None, "null", []], ids=["empty", "null", "array"]) +def test_an_unpaginated_list_reads_null_as_empty( + make_client: Callable[..., Any], payload: Any +) -> None: + client, _ = make_client(respond(200, payload)) + assert client.instances.list_usage("acme-production") == [] + + +def test_the_manifest_is_its_list_of_flags(make_client: Callable[..., Any]) -> None: + flag = {"key": "new-checkout", "type": "boolean", "defaultValue": False} + client, _ = make_client(respond(200, {"flags": [flag]})) + assert client.feature_flags.manifest() == [ManifestFlag.model_validate(flag)] + + +def test_a_platform_id_must_be_a_uuid(make_platform_client: Callable[..., Any]) -> None: + client, recorder = make_platform_client(respond(201, TOKEN)) + with pytest.raises(ValueError, match="organization_id"): + client.tokens.mint("acme", name="billing-sync") + assert not recorder + + +@pytest.mark.parametrize( + ("ttl", "wire"), + [ + (timedelta(minutes=15), "900s"), + (timedelta(hours=24), "86400s"), + (timedelta(milliseconds=1500), "1.5s"), + ("15m", "15m"), + ], +) +def test_a_token_ttl_is_sent_as_a_go_duration( + make_platform_client: Callable[..., Any], ttl: timedelta | str, wire: str +) -> None: + client, recorder = make_platform_client(respond(201, TOKEN)) + minted = client.tokens.mint(ORG_ID, name="billing-sync", ttl=ttl) + assert body(recorder.only) == {"name": "billing-sync", "ttl": wire} + assert (minted.token, minted.slug) == ("ksh_plaintext", "system-kaiten-2c8293") + + +def test_a_token_ttl_must_be_positive(make_platform_client: Callable[..., Any]) -> None: + client, recorder = make_platform_client(respond(201, TOKEN)) + with pytest.raises(ValueError, match="positive"): + client.tokens.mint(ORG_ID, name="billing-sync", ttl=timedelta(0)) + assert not recorder + + +def test_registering_a_connector_sends_an_empty_schema_by_default( + make_platform_client: Callable[..., Any], +) -> None: + connector = { + "name": "kaiten.integration.crm.acme", + "version": "1.0.0", + "settings_schema": {}, + "created_at": "2026-09-14T12:00:00Z", + "updated_at": "2026-09-14T12:00:00Z", + } + client, recorder = make_platform_client(respond(200, connector)) + client.connectors.register(name="kaiten.integration.crm.acme", version="1.0.0") + assert body(recorder.only) == { + "name": "kaiten.integration.crm.acme", + "version": "1.0.0", + "settings_schema": {}, + } diff --git a/tests/test_retries.py b/tests/test_retries.py new file mode 100644 index 0000000..6a5c630 --- /dev/null +++ b/tests/test_retries.py @@ -0,0 +1,205 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""What is retried, and what never is. + +A retry is only safe when repeating the request cannot change the outcome. A usage report +retried after its response was lost is usage counted twice -- and usage is what customers are +billed on -- so a POST is never retried unless the connection never opened. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from helpers import ORG_ID, Handler, Recorder + +from kaitencloud import ( + APITimeoutError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + ServiceUnavailableError, +) +from kaitencloud._utils import retry_delay + +GROUP = {"id": "g0", "name": "AI quotas", "description": None} +PAGE = {"items": [GROUP], "hasMore": False} +CUSTOMER = { + "id": "c1", + "name": "Acme", + "createdAt": "2026-09-14T12:00:00Z", + "updatedAt": "2026-09-14T12:00:00Z", + "createdBy": {"id": "u1"}, + "updatedBy": {"id": "u1"}, +} +ORGANIZATION = {"id": ORG_ID, "external_id": "org_2abc", "name": "Acme"} + +Step = Callable[[httpx.Request], httpx.Response] + + +def script(*steps: Step) -> Handler: + """A fake API giving each request the next scripted answer.""" + pending = list(steps) + + def handler(request: httpx.Request) -> httpx.Response: + assert pending, "the client sent more requests than were scripted" + return pending.pop(0)(request) + + return handler + + +def status(code: int, payload: Any = None, headers: dict[str, str] | None = None) -> Step: + return lambda request: httpx.Response(code, json=payload, headers=headers) + + +def fail(error: type[httpx.TransportError]) -> Step: + def raise_error(request: httpx.Request) -> httpx.Response: + raise error("scripted failure", request=request) + + return raise_error + + +def test_a_read_is_retried_after_a_server_error( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(503), status(200, PAGE))) + assert len(client.entitlement_groups.list()) == 1 + assert len(recorder) == 2 + assert len(delays) == 1 + + +def test_retries_stop_after_max_retries( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(500), status(500), status(500)), max_retries=2) + with pytest.raises(InternalServerError): + client.customers.get("acme") + assert len(recorder) == 3 + assert len(delays) == 2 + + +def test_no_retries_sends_exactly_once( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(503)), max_retries=0) + with pytest.raises(ServiceUnavailableError): + client.customers.get("acme") + assert len(recorder) == 1 + assert delays == [] + + +def test_a_create_is_not_retried_after_a_server_error( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(503))) + with pytest.raises(ServiceUnavailableError): + client.customers.create(name="Acme") + assert len(recorder) == 1 + + +def test_a_usage_report_is_not_retried_when_its_response_is_lost( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(fail(httpx.ReadTimeout))) + with pytest.raises(APITimeoutError): + client.instances.report_usage("acme-production", "seats", 1) + assert len(recorder) == 1 + + +@pytest.mark.parametrize("error", [httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout]) +def test_a_create_is_retried_when_the_request_never_left( + make_client: Callable[..., Any], delays: list[float], error: type[httpx.TransportError] +) -> None: + client, recorder = make_client(script(fail(error), status(201, CUSTOMER))) + assert client.customers.create(name="Acme").name == "Acme" + assert len(recorder) == 2 + + +def test_a_read_is_retried_after_a_timeout( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(fail(httpx.ReadTimeout), status(200, PAGE))) + client.entitlement_groups.list() + assert len(recorder) == 2 + + +def test_a_full_replacement_is_retried( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(502), status(204))) + client.customers.update("acme", name="Acme") + assert len(recorder) == 2 + + +def test_setting_a_status_is_retried_because_it_converges( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(503), status(204))) + client.instances.update_status("acme-production", "HEALTHY") + assert len(recorder) == 2 + + +def test_a_reorder_is_retried_although_it_is_a_post( + make_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_client(script(status(503), status(204))) + client.metadata_fields.reorder(["f1", "f2"]) + assert len(recorder) == 2 + + +def test_ensuring_an_organization_is_retried_because_it_is_an_upsert( + make_platform_client: Callable[..., Any], delays: list[float] +) -> None: + client, recorder = make_platform_client(script(status(503), status(200, ORGANIZATION))) + assert client.organizations.ensure(external_id="org_2abc").id == ORG_ID + assert len(recorder) == 2 + + +@pytest.mark.parametrize( + ("code", "error"), [(401, AuthenticationError), (403, PermissionDeniedError)] +) +def test_a_refused_credential_is_never_retried( + make_client: Callable[..., Any], delays: list[float], code: int, error: type[Exception] +) -> None: + client, recorder = make_client(script(status(code, {"title": "Refused"}))) + with pytest.raises(error): + client.customers.list() + assert len(recorder) == 1 + + +def test_retry_after_is_honoured(make_client: Callable[..., Any], delays: list[float]) -> None: + client, _ = make_client(script(status(429, headers={"Retry-After": "3"}), status(200, PAGE))) + client.entitlement_groups.list() + assert delays == [3.0] + + +def test_retry_after_is_capped(make_client: Callable[..., Any], delays: list[float]) -> None: + client, _ = make_client( + script(status(503, headers={"Retry-After": "86400"}), status(200, PAGE)) + ) + client.entitlement_groups.list() + assert delays == [60.0] + + +@pytest.mark.parametrize("attempt", range(8)) +def test_backoff_grows_with_jitter_and_stays_bounded(attempt: int) -> None: + for _ in range(50): + delay = retry_delay(attempt) + assert min(0.25 * 2**attempt, 8.0) <= delay <= 8.0 + + +def test_the_async_client_retries_the_same_way( + make_async_client: Callable[..., Any], delays: list[float] +) -> None: + async def scenario() -> Recorder: + client, recorder = make_async_client(script(status(503), status(200, PAGE))) + await client.entitlement_groups.list() + return recorder + + assert len(asyncio.run(scenario())) == 2 + assert len(delays) == 1 diff --git a/tests/test_targeting.py b/tests/test_targeting.py new file mode 100644 index 0000000..edd6bb6 --- /dev/null +++ b/tests/test_targeting.py @@ -0,0 +1,62 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The feature flag builders produce the shapes the contract describes.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from kaitencloud import targeting + +START = datetime(2026, 9, 14, tzinfo=timezone.utc) + + +def test_variants_and_default_variants() -> None: + assert targeting.variant("on", True) == {"name": "on", "value": True, "description": ""} + assert targeting.fixed("off") == {"type": "basic", "value": "off"} + assert targeting.split({"on": 20, "off": 80}) == { + "type": "rollout_percentage", + "distribution": {"on": 20, "off": 80}, + } + assert targeting.schedule( + start=targeting.step("on", 0, START), end=targeting.step("on", 100, "2026-10-14T00:00:00Z") + ) == { + "type": "rollout_date", + "start": {"variant": "on", "percentage": 0, "date": "2026-09-14T00:00:00Z"}, + "end": {"variant": "on", "percentage": 100, "date": "2026-10-14T00:00:00Z"}, + } + + +def test_rules() -> None: + assert targeting.rule("Growth", "__kaiten.license.slug == 'growth'", "on") == { + "type": "basic", + "name": "Growth", + "rule": "__kaiten.license.slug == 'growth'", + "variant": "on", + } + assert targeting.split_rule("Rollout", "true", {"on": 1, "off": 3})["type"] == ( + "rollout_percentage" + ) + rule = targeting.schedule_rule( + "Launch", "true", start=targeting.step("on", 0, START), end=targeting.step("on", 100, START) + ) + assert (rule["type"], rule["name"]) == ("rollout_date", "Launch") + + +@pytest.mark.parametrize( + "distribution", + [{}, {"on": 0, "off": 100}, {"on": -5}, {"on": 2.5}, {"on": True}], + ids=["empty", "zero weight", "negative", "fractional", "boolean"], +) +def test_a_split_needs_positive_integer_weights(distribution: dict[str, object]) -> None: + with pytest.raises(ValueError, match="split"): + targeting.split(distribution) # type: ignore[arg-type] + + +@pytest.mark.parametrize("percentage", [-1, 101]) +def test_a_step_percentage_is_between_0_and_100(percentage: int) -> None: + with pytest.raises(ValueError, match="percentage"): + targeting.step("on", percentage, START) diff --git a/tests/test_usage.py b/tests/test_usage.py new file mode 100644 index 0000000..a7978b4 --- /dev/null +++ b/tests/test_usage.py @@ -0,0 +1,58 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The enforcement arithmetic, on the boundaries the API refuses reports at.""" + +from __future__ import annotations + +import pytest + +from kaitencloud import UNLIMITED, usage + + +def test_a_hard_limit_caps_usage_at_its_value() -> None: + assert usage.maximum_allowed_usage(100, 0) == 100 + assert usage.is_hard_limit(100, 0) + assert not usage.is_soft_limit(100, 0) + assert usage.accepts(100, 100, 0) + assert not usage.accepts(100, 101, 0) + + +def test_a_soft_limit_tolerates_its_overage_percent() -> None: + assert usage.maximum_allowed_usage(100, 10) == 110 + assert usage.is_soft_limit(100, 10) + assert usage.accepts(100, 105, 10) + assert usage.accepts(100, 110, 10) + assert not usage.accepts(100, 110.5, 10) + + +def test_an_unlimited_grant_has_no_cap() -> None: + assert usage.is_unlimited(UNLIMITED) + assert usage.maximum_allowed_usage(UNLIMITED, -1) is None + assert usage.remaining(UNLIMITED, 10**9, -1) is None + assert usage.percentage_used(UNLIMITED, 10**9) is None + assert usage.accepts(UNLIMITED, 10**12, -1) + assert not usage.is_hard_limit(UNLIMITED, -1) + assert not usage.is_soft_limit(UNLIMITED, -1) + + +def test_zero_is_a_real_cap() -> None: + assert usage.maximum_allowed_usage(0, 0) == 0 + assert not usage.accepts(0, 1, 0) + assert usage.remaining(0, 0, 0) == 0 + + +def test_remaining_counts_down_to_the_cap_not_to_the_value() -> None: + assert usage.remaining(100, 105, 10) == 5 + assert usage.remaining(100, 120, 10) == 0 + + +def test_percentage_used_is_measured_against_the_value_and_can_pass_100() -> None: + assert usage.percentage_used(100, 80) == 80 + assert usage.percentage_used(100, 105) == pytest.approx(105) + assert usage.percentage_used(0, 0) is None + + +def test_a_missing_overage_percent_reads_as_a_hard_limit() -> None: + assert usage.maximum_allowed_usage(30, None) == 30 + assert usage.is_hard_limit(30, None) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..8263d4c --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,185 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Webhook deliveries: verified like Svix signs them, parsed into their typed events.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from datetime import timedelta +from typing import Any + +import pytest +from contract import document, sample + +from kaitencloud import WebhookPayloadError, WebhookVerificationError +from kaitencloud.webhooks import ( + WEBHOOK_EVENTS, + InstanceEntitlementCapExceededEvent, + UnknownWebhookEvent, + Webhook, + parse_event, + verify_webhook, +) + +SECRET = "whsec_" + base64.b64encode(b"kaiten-test-signing-key-32-bytes").decode() +EVENT = { + "name": "INSTANCE_ENTITLEMENT_CAP_EXCEEDED", + "type": "com.kaiten.instance.entitlement.v1.cap_exceeded", + "data": {"entitlement_slug": "seats", "threshold": 100, "value": 105, "overage": 5}, + "headers": {}, +} +PAYLOAD = json.dumps(EVENT).encode() + + +def sign( + payload: bytes, *, secret: str = SECRET, message_id: str = "msg_2abc", at: float | None = None +) -> dict[str, str]: + """The headers Svix sends with a delivery of ``payload``.""" + timestamp = str(int(time.time() if at is None else at)) + key = base64.b64decode(secret.removeprefix("whsec_")) + digest = hmac.new(key, f"{message_id}.{timestamp}.".encode() + payload, hashlib.sha256) + return { + "svix-id": message_id, + "svix-timestamp": timestamp, + "svix-signature": "v1," + base64.b64encode(digest.digest()).decode(), + } + + +def test_a_signed_delivery_verifies_into_its_typed_event() -> None: + event = verify_webhook(PAYLOAD, sign(PAYLOAD), secret=SECRET) + assert isinstance(event, InstanceEntitlementCapExceededEvent) + assert (event.data.entitlement_slug, event.data.overage) == ("seats", 5) + + +def test_svix_reference_signature_verifies(monkeypatch: pytest.MonkeyPatch) -> None: + # The worked example of Svix's documentation, cross-checked against the svix library. + monkeypatch.setattr(time, "time", lambda: 1614265330.0) + headers = { + "svix-id": "msg_p5jXN8AQM9LWM0D4loKWxJek", + "svix-timestamp": "1614265330", + "svix-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=", + } + # The signature matches, so verification reaches parsing -- and this payload is no event. + with pytest.raises(WebhookPayloadError): + verify_webhook( + '{"test": 2432232314}', headers, secret="whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" + ) + + +def test_a_tampered_payload_is_refused() -> None: + headers = sign(PAYLOAD) + with pytest.raises(WebhookVerificationError, match="signature"): + verify_webhook(PAYLOAD.replace(b"105", b"999"), headers, secret=SECRET) + + +def test_a_delivery_signed_with_another_secret_is_refused() -> None: + other = "whsec_" + base64.b64encode(b"another-endpoint-secret").decode() + with pytest.raises(WebhookVerificationError): + verify_webhook(PAYLOAD, sign(PAYLOAD, secret=other), secret=SECRET) + + +@pytest.mark.parametrize("offset", [-301, 301], ids=["too old", "in the future"]) +def test_a_delivery_outside_the_tolerance_is_refused_either_way(offset: int) -> None: + headers = sign(PAYLOAD, at=time.time() + offset) + with pytest.raises(WebhookVerificationError, match="timestamp"): + verify_webhook(PAYLOAD, headers, secret=SECRET) + + +def test_the_tolerance_can_be_widened() -> None: + headers = sign(PAYLOAD, at=time.time() - 600) + event = verify_webhook(PAYLOAD, headers, secret=SECRET, tolerance=timedelta(minutes=15)) + assert isinstance(event, InstanceEntitlementCapExceededEvent) + + +def test_a_timestamp_that_is_not_a_number_is_refused() -> None: + headers = {**sign(PAYLOAD), "svix-timestamp": "yesterday"} + with pytest.raises(WebhookVerificationError, match="timestamp"): + verify_webhook(PAYLOAD, headers, secret=SECRET) + + +def test_any_matching_signature_verifies_during_a_secret_rotation() -> None: + headers = sign(PAYLOAD) + headers["svix-signature"] = "v1,c3RhbGU= " + headers["svix-signature"] + assert isinstance( + verify_webhook(PAYLOAD, headers, secret=SECRET), InstanceEntitlementCapExceededEvent + ) + + +def test_only_v1_signatures_count() -> None: + headers = sign(PAYLOAD) + headers["svix-signature"] = headers["svix-signature"].replace("v1,", "v2,") + with pytest.raises(WebhookVerificationError): + verify_webhook(PAYLOAD, headers, secret=SECRET) + + +@pytest.mark.parametrize("missing", ["svix-id", "svix-timestamp", "svix-signature"]) +def test_a_missing_header_is_refused(missing: str) -> None: + headers = sign(PAYLOAD) + del headers[missing] + with pytest.raises(WebhookVerificationError, match=missing): + verify_webhook(PAYLOAD, headers, secret=SECRET) + + +def test_unbranded_webhook_headers_are_accepted() -> None: + headers = {name.replace("svix-", "webhook-"): value for name, value in sign(PAYLOAD).items()} + assert isinstance( + verify_webhook(PAYLOAD, headers, secret=SECRET), InstanceEntitlementCapExceededEvent + ) + + +def test_header_names_are_matched_case_insensitively() -> None: + headers = {name.title(): value for name, value in sign(PAYLOAD).items()} + assert isinstance( + verify_webhook(PAYLOAD, headers, secret=SECRET), InstanceEntitlementCapExceededEvent + ) + + +def test_a_text_payload_verifies_like_bytes() -> None: + event = Webhook(SECRET).verify(PAYLOAD.decode(), sign(PAYLOAD)) + assert isinstance(event, InstanceEntitlementCapExceededEvent) + + +@pytest.mark.parametrize("secret", ["", "whsec_", "whsec_not base64!"]) +def test_a_malformed_secret_is_refused_upfront(secret: str) -> None: + with pytest.raises(ValueError, match="whsec_"): + Webhook(secret) + + +def test_an_event_newer_than_the_sdk_parses_as_unknown() -> None: + payload = json.dumps( + {"name": "INVOICE_PAID", "type": "com.kaiten.invoice.v1.paid", "data": {"amount": 1}} + ).encode() + event = verify_webhook(payload, sign(payload), secret=SECRET) + assert isinstance(event, UnknownWebhookEvent) + assert (event.type, event.data) == ("com.kaiten.invoice.v1.paid", {"amount": 1}) + + +def test_an_authentic_payload_that_breaks_its_contract_is_a_payload_error() -> None: + payload = json.dumps({**EVENT, "data": {"entitlement_slug": "seats"}}).encode() + with pytest.raises(WebhookPayloadError, match="cap_exceeded"): + verify_webhook(payload, sign(payload), secret=SECRET) + + +@pytest.mark.parametrize("payload", [b"not json", b"[1, 2]"], ids=["not json", "not an object"]) +def test_a_payload_that_is_not_an_event_object_is_a_payload_error(payload: bytes) -> None: + with pytest.raises(WebhookPayloadError): + parse_event(payload) + + +@pytest.mark.parametrize("event_type", sorted(document("core")["webhooks"])) +def test_every_published_event_parses_a_payload_shaped_like_its_contract(event_type: str) -> None: + schema = document("core")["webhooks"][event_type]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + + full: dict[str, Any] = sample(schema, "core") + event = parse_event(full) + assert type(event) is WEBHOOK_EVENTS[event_type] + assert event.to_dict() == full + + assert type(parse_event(sample(schema, "core", full=False))) is WEBHOOK_EVENTS[event_type] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a7844f2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1460 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +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.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[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.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[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 = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/62/a8b4dd53308a4b1571e6bdd77ee562c9b1c1742db9886c0aef2f69463067/coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", size = 223178, upload-time = "2026-09-13T19:08:21.805Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/b9325b8d486a5c05fd4a9eb1cd9c9f697c361ddeda0e73567d5c2d8959f6/coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", size = 223701, upload-time = "2026-09-13T19:08:23.391Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ee/3ab3ac6e9e7165a623695b73a83d1bfbdf5069681a68a012cb5abb096060/coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", size = 250434, upload-time = "2026-09-13T19:08:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/31ffc231e9879876f611cf1ac7622cf092d76762c07ca64dce47b29a200b/coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", size = 252264, upload-time = "2026-09-13T19:08:26.734Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f4/4fb4f6b16de07dbae3d3a111ae28d32ad657ce6ff4d364c49c10962daad5/coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", size = 254127, upload-time = "2026-09-13T19:08:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/048e88eb610f44b7542dac10fa45bb2ffad5fbfcdab0f03289a843da934b/coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", size = 256043, upload-time = "2026-09-13T19:08:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/97/59/d27b113c80b04c7fc64c69457132648e1d287b83a1de41189a504b50b52b/coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", size = 251092, upload-time = "2026-09-13T19:08:31.57Z" }, + { url = "https://files.pythonhosted.org/packages/45/42/b7eafaceff08dc1dcbfc924fa9233031fd00c8b8287cc4a1a0dcdedcb0f9/coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9", size = 252170, upload-time = "2026-09-13T19:08:33.191Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/57dcc5360aa6d8d24370c12fe855f6ebf5fbf2718c154fb9fdec7161de07/coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", size = 250173, upload-time = "2026-09-13T19:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/379a8c7ac0f3063769dc43fd2475e9fc9b2722cb88eabe71f4d862660c93/coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", size = 253986, upload-time = "2026-09-13T19:08:36.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/25/70218d2a6077730843b75ee2525fa64a750283d71cc4c063dc0b75ba6979/coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", size = 250444, upload-time = "2026-09-13T19:08:38.434Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/6c41c441baa6c8a1fed5615e4e1e7d0ca649b33b472ee28591a92d70a1ff/coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", size = 251046, upload-time = "2026-09-13T19:08:40.133Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/01166d6f8aa42acd148b4c2c88173d60eefdd0270ea2bcbba79be35bc193/coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", size = 225242, upload-time = "2026-09-13T19:08:41.672Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/a76060478919becf0fbea2df2053a6e1a7195f11edf8dfd51c58c1636b43/coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", size = 225868, upload-time = "2026-09-13T19:08:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/ae/16/e11addf5322d98e86307da9e00c94644b97cb72c534c74cda89705bebeef/coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", size = 223544, upload-time = "2026-09-13T19:11:09.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/d9/7ccd6484f615961d94b09c75904f87d86375109596069ae3a495a205dbbe/coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", size = 223882, upload-time = "2026-09-13T19:11:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/59/37/2fd78152a557df4a7e4ad78d6851ede90c211838d526e4916c6016f45144/coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", size = 254987, upload-time = "2026-09-13T19:11:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/58e2f9b8b13cc422aa7f474a498945ec7f446ef473b5b6bae18d2981f2a9/coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", size = 257902, upload-time = "2026-09-13T19:11:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/63f8f6a67c03b86455c726be273b2aa5b57fd1122f22350d141bbb142e91/coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", size = 259522, upload-time = "2026-09-13T19:11:18.206Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/1f2261da7483e345fe55de11e41ffc16a543511e37c0912134c0dccc4f6c/coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", size = 261723, upload-time = "2026-09-13T19:11:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8a/7eb1a361e044b7293f49c921a974b532627ed37f3bef5e7e9f58bcd0d5a2/coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", size = 255462, upload-time = "2026-09-13T19:11:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/67/66/6d94f7ea87e99c519def5cfa5d4a1ab20247d6b403eb6e6b5b63d6aa5985/coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", size = 257617, upload-time = "2026-09-13T19:11:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/3d/52/c3de0a3868589a1463a4f1cb0222eca0e5fa9f91da865ccf20d2e19ec19c/coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", size = 255495, upload-time = "2026-09-13T19:11:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a3/ea10e75f20fc1e7be10b166a494826a965d19b1dc9c8a81c511a01153311/coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", size = 259728, upload-time = "2026-09-13T19:11:29.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/96/9d8020d97de46f0390739beda196322586cdd87e1fc357223e6a28bc4135/coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", size = 254904, upload-time = "2026-09-13T19:11:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/5b/21/2ff8867d4560f4f639a82d8fcf13c27eea88a96193daba096027c48bb929/coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", size = 256828, upload-time = "2026-09-13T19:11:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4b/feacad51cb5163e3baa13281f8a3098f15d7934cc1f28cfe656557eb4e46/coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", size = 225722, upload-time = "2026-09-13T19:11:36.314Z" }, + { url = "https://files.pythonhosted.org/packages/39/34/66bbc6ffd3c51fa67604c566920e772c5e3baa57f88dcf7b109c7f18b2cb/coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", size = 226195, upload-time = "2026-09-13T19:11:38.493Z" }, + { url = "https://files.pythonhosted.org/packages/f7/96/4bad920d4caed127c37c136a64b0730fa495517d2eb58809e0d81e6e7dff/coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", size = 225625, upload-time = "2026-09-13T19:11:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2b/a88c7906eeb7da4394e932060d7008e153b3a2cd8d11782d82faa03bb7a3/coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", size = 224271, upload-time = "2026-09-13T19:11:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1f/98db59c595680d16f553890deac6d72610e04f192c1964ec9b69cbe790ab/coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", size = 224564, upload-time = "2026-09-13T19:11:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/699e236d4cb97ec282b0655afc85acf12349e71a02fff0f1a7d0fb643079/coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", size = 265423, upload-time = "2026-09-13T19:11:47.666Z" }, + { url = "https://files.pythonhosted.org/packages/96/1b/6eaa21912863b3e3bf23cfe605fb62929bb1a41dc33d2bfa8e92000232bd/coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", size = 268503, upload-time = "2026-09-13T19:11:50.12Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4eb46bffa98c28a80559a58c12f17e19308d52b31174af1bd49decf76606/coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", size = 271059, upload-time = "2026-09-13T19:11:52.363Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d4/17a51ef1f6a084c9abbead522cfbee0fe9d29a593128781e724b7a0795b3/coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", size = 272039, upload-time = "2026-09-13T19:11:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/9fdeba8b8aa0848030f45f869ddedc306795a8c6e53e901b8553afbf5415/coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", size = 265869, upload-time = "2026-09-13T19:11:57.423Z" }, + { url = "https://files.pythonhosted.org/packages/95/eb/ab3eb506b2e4dd278440fbbbc7ed18424a86d8a981db0b90fdba66f5d34d/coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", size = 268883, upload-time = "2026-09-13T19:11:59.71Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/f4edefdce33f01954a74237df5ee83ccef5dcd1165e04abf0adf47543fca/coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", size = 265359, upload-time = "2026-09-13T19:12:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/df/86/ce5ed885fc7ce2adc9a5971134ace257d0f84f19c3d117da89c179b9662e/coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", size = 270056, upload-time = "2026-09-13T19:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/5dbd5f95070cece25c6df9e6ea4ded2a87dc695ce82a49bcb2116452b342/coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", size = 265498, upload-time = "2026-09-13T19:12:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ff/254b26f3300f87c7c53ac97da41ae75f0e2ac066aa964bc073c0f3750b38/coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", size = 267459, upload-time = "2026-09-13T19:12:09.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/20/bf9958f8ddbbf6a2d39da3d4009f33e0fcf19c2d2fd99715c6494cdc8e8c/coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", size = 226021, upload-time = "2026-09-13T19:12:11.933Z" }, + { url = "https://files.pythonhosted.org/packages/35/8f/12d46948704d9e437c8dca2718e5d54ecc2f85396b64b500c29d8dbe10db/coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", size = 226853, upload-time = "2026-09-13T19:12:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7f/1ca5fd0601054fce5a3539375b997e67683646e2bf62f60c41e70b529fcc/coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", size = 226163, upload-time = "2026-09-13T19:12:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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 = "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 = "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.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kaitencloud" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "jsonschema" }, + { name = "mypy" }, + { name = "openfeature-provider-ofrep" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pyyaml" }, + { name = "ruff" }, + { name = "types-jsonschema" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "pydantic", specifier = ">=2.11,<3" }, + { name = "typing-extensions", specifier = ">=4.12.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio", specifier = ">=4.6" }, + { name = "jsonschema", specifier = ">=4.23" }, + { name = "mypy", specifier = ">=2.3" }, + { name = "openfeature-provider-ofrep", specifier = ">=0.3.0" }, + { name = "pytest", specifier = ">=9.0" }, + { name = "pytest-cov", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.16" }, + { name = "types-jsonschema", specifier = ">=4.23" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "openfeature-provider-ofrep" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openfeature-sdk" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/61/710d8910ed91967fc3ced209e73f28bd38954583c74d2017006cc10c61e3/openfeature_provider_ofrep-0.3.0.tar.gz", hash = "sha256:aab6f08a56008b338b24de1c180130db902df7d5b702ed37e961e2f1ad73e0fb", size = 7698, upload-time = "2026-05-07T20:19:57.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/6b/86e6c383c19055a620ff87e9fb684566ce6d5be1db136020067724c714d4/openfeature_provider_ofrep-0.3.0-py3-none-any.whl", hash = "sha256:7d12cba212a863aa837fa56019281e19c484cf922c5962a24dc041eda32d0672", size = 8894, upload-time = "2026-05-07T20:19:56.079Z" }, +] + +[[package]] +name = "openfeature-sdk" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/cfc684b7d8314398d476ae8ed515c10db99c4d7f950989db464b4ded12ce/openfeature_sdk-0.10.0.tar.gz", hash = "sha256:938c2540bdea4da3b01ef507517ee636f223a35abaaca845c5587e594151b052", size = 33516, upload-time = "2026-06-01T19:45:35.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/44/8a4f5225e930ff0d999fd43f5d743a4babeb6c7e76dddc00f0e118878ef3/openfeature_sdk-0.10.0-py3-none-any.whl", hash = "sha256:75497ea75d73f684eef509a25f79ad6386368862e050af80ab70a44ae49b33e4", size = 38941, upload-time = "2026-06-01T19:45:33.011Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[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 = "pydantic" +version = "2.13.5" +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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[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 = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[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 = "types-jsonschema" +version = "4.26.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +]