From 5cc69e748aa713f7080e78055da8bcf77bf8ae08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20Csirmaz?= Date: Thu, 2 Apr 2026 14:02:15 +0200 Subject: [PATCH] feat(dir-sdk-python): initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bendegúz Csirmaz --- .github/CODEOWNERS | 6 +- .github/ISSUE_TEMPLATE/bug_report.yml | 7 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/pull_request_template.md | 2 +- .github/settings.yml | 112 +- .github/workflows/ci.yaml | 83 + .github/workflows/dependency.yml | 9 +- .github/workflows/lint.yml | 15 +- .github/workflows/reusable-test-sdk.yaml | 53 + .gitignore | 71 + .python-version | 1 + CHANGELOG.md | 11 +- CONTRIBUTING.md | 12 +- LICENSE.md | 176 ++ MAINTAINERS.md | 9 +- README.md | 208 +-- SECURITY.md | 8 +- Taskfile.deps.yml | 95 + Taskfile.test-env.yml | 163 ++ Taskfile.vars.yml | 20 + Taskfile.yml | 114 ++ dir-sdk-python/agntcy/dir_sdk/__init__.py | 0 .../agntcy/dir_sdk/client/__init__.py | 5 + .../agntcy/dir_sdk/client/client.py | 1605 +++++++++++++++++ .../agntcy/dir_sdk/client/config.py | 155 ++ .../agntcy/dir_sdk/client/test_client.py | 568 ++++++ .../agntcy/dir_sdk/models/README.md | 13 + .../agntcy/dir_sdk/models/__init__.py | 13 + .../agntcy/dir_sdk/models/core_v1.py | 5 + .../agntcy/dir_sdk/models/events_v1.py | 5 + .../agntcy/dir_sdk/models/naming_v1.py | 6 + .../agntcy/dir_sdk/models/routing_v1.py | 11 + .../agntcy/dir_sdk/models/search_v1.py | 7 + .../agntcy/dir_sdk/models/sign_v1.py | 7 + .../agntcy/dir_sdk/models/store_v1.py | 7 + pyproject.toml | 45 + uv.lock | 591 ++++++ 37 files changed, 4016 insertions(+), 204 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/reusable-test-sdk.yaml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 LICENSE.md create mode 100644 Taskfile.deps.yml create mode 100644 Taskfile.test-env.yml create mode 100644 Taskfile.vars.yml create mode 100644 Taskfile.yml create mode 100644 dir-sdk-python/agntcy/dir_sdk/__init__.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/client/__init__.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/client/client.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/client/config.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/client/test_client.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/README.md create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/__init__.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/core_v1.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/events_v1.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/naming_v1.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/routing_v1.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/search_v1.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/sign_v1.py create mode 100644 dir-sdk-python/agntcy/dir_sdk/models/store_v1.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e45219e..0d8c82d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ #################################################### # -# List of approvers for project +# List of approvers for the Directory Python SDK project # ##################################################### # @@ -12,8 +12,8 @@ # the repository. Unless a later match takes precedence, # the following users/teams will be requested for # review when someone opens a pull request. -* @agntcy/ +* @agntcy/dir-maintainers # Enforces admin protections for repo configuration via probot settings app. # ref: https://github.com/probot/settings#security-implications -.github/settings.yml @agntcy/ +.github/settings.yml @agntcy/dir-maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a06ee2f..0c590d7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -30,10 +30,7 @@ body: If applicable, provide the version number or release tag where this issue was encountered options: - - v1.0.0 - - v1.0.1 - - v1.0.2 - - v1.0.3 + - v1.1.0 default: 0 validations: required: false @@ -55,7 +52,7 @@ body: label: Checklist description: By submitting this issue, you agree to the following options: - - label: I have read the [contributing guidelines](/agntcy/repo-template/blob/main/CONTRIBUTING.md) + - label: I have read the [contributing guidelines](/agntcy/dir-sdk-python/blob/main/CONTRIBUTING.md) required: true - label: I have verified this does not duplicate an existing issue required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index e1c9470..903286e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -48,7 +48,7 @@ body: label: Checklist description: By submitting this request, you agree to the following options: - - label: I have read the [contributing guidelines](/agntcy/repo-template/blob/main/CONTRIBUTING.md) + - label: I have read the [contributing guidelines](/agntcy/dir-sdk-python/blob/main/CONTRIBUTING.md) required: true - label: | I have verified this does not duplicate an existing feature request diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3392ce7..ceda9dd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,7 +19,7 @@ much longer to review, or result in it not being reviewed at all. ## Checklist -- [ ] I have read the [contributing guidelines](/agntcy/repo-template/blob/main/CONTRIBUTING.md) +- [ ] I have read the [contributing guidelines](/agntcy/dir-sdk-python/blob/main/CONTRIBUTING.md) - [ ] Existing issues have been referenced (where applicable) - [ ] I have verified this change is not present in other open pull requests - [ ] Functionality is documented diff --git a/.github/settings.yml b/.github/settings.yml index 69fdc66..dcb920d 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -5,13 +5,13 @@ repository: # See https://developer.github.com/v3/repos/#edit for all available settings. # The name of the repository. Changing this will rename the repository - name: repo-template + name: dir-sdk-python # A short description of the repository that will show up on GitHub - description: Agntcy Project Repo Template + description: Directory Python SDK # A URL with more information about the repository - homepage: https://www.example.com + homepage: https://docs.agntcy.org/dir/directory-sdk/#python-sdk # A comma-separated list of topics to set on the repository topics: github, probot @@ -60,60 +60,56 @@ repository: # TODO: Enable this section once GitHub teams have been created & granted access to this repository # See https://developer.github.com/v3/teams/#add-or-update-team-repository for options -# teams: -# # The permission to grant the team. Can be one of: -# # * `pull` - can pull, but not push to or administer this repository. -# # * `push` - can pull and push, but not administer this repository. -# # * `admin` - can pull, push and administer this repository. -# # * `maintain` - Recommended for project managers who need to manage the -# # repository without access to sensitive or destructive actions. -# # * `triage` - Recommended for contributors who need to proactively manage -# # issues and pull requests without write access. -# - name: -# permission: admin -# - name: -# permission: maintain -# - name: -# permission: triage +# teams: +# # The permission to grant the team. Can be one of: +# # * `pull` - can pull, but not push to or administer this repository. +# # * `push` - can pull and push, but not administer this repository. +# # * `admin` - can pull, push and administer this repository. +# # * `maintain` - Recommended for project managers who need to manage the +# # repository without access to sensitive or destructive actions. +# # * `triage` - Recommended for contributors who need to proactively manage +# # issues and pull requests without write access. +# - name: dir-maintainers +# permission: admin # TODO: Enable this section once repository has been created & no further modifications are needed -# branches: -# - name: main -# # https://developer.github.com/v3/repos/branches/#update-branch-protection -# # Branch Protection settings. Set to null to disable -# protection: -# # Required. Require at least one approving review on a pull request, -# # before merging. Set to null to disable. -# required_pull_request_reviews: -# # The number of approvals required. (1-6) -# required_approving_review_count: 1 -# # Dismiss approved reviews automatically when a new commit is pushed. -# dismiss_stale_reviews: true -# # Blocks merge until code owners have reviewed. -# require_code_owner_reviews: true -# # Specify which users and teams can dismiss pull request reviews. Pass -# # an empty dismissal_restrictions object to disable. User and team -# # dismissal_restrictions are only available for organization-owned -# # repositories. Omit this parameter for personal repositories. -# dismissal_restrictions: -# users: [] -# teams: [] -# # Required. Require status checks to pass before merging. Set to null to disable -# required_status_checks: -# # Required. Require branches to be up to date before merging. -# strict: true -# # Required. The list of required status checks in order to merge into this branch -# contexts: [] -# # Required. Enforce all configured restrictions for administrators. Set to -# # true to enforce required status checks for repository administrators. -# # Set to null to disable. -# enforce_admins: true -# # Prevent merge commits from being pushed to matching branches -# required_linear_history: true -# # Required. Restrict who can push to this branch. Team and user -# # restrictions are only available for organization-owned repositories. Set -# # to null to disable. -# restrictions: -# apps: [] -# users: [] -# teams: [] +#branches: +# - name: main +# # https://developer.github.com/v3/repos/branches/#update-branch-protection +# # Branch Protection settings. Set to null to disable +# protection: +# # Required. Require at least one approving review on a pull request, +# # before merging. Set to null to disable. +# required_pull_request_reviews: +# # The number of approvals required. (1-6) +# required_approving_review_count: 1 +# # Dismiss approved reviews automatically when a new commit is pushed. +# dismiss_stale_reviews: true +# # Blocks merge until code owners have reviewed. +# require_code_owner_reviews: true +# # Specify which users and teams can dismiss pull request reviews. Pass +# # an empty dismissal_restrictions object to disable. User and team +# # dismissal_restrictions are only available for organization-owned +# # repositories. Omit this parameter for personal repositories. +# dismissal_restrictions: +# users: [] +# teams: [] +# # Required. Require status checks to pass before merging. Set to null to disable +# required_status_checks: +# # Required. Require branches to be up to date before merging. +# strict: true +# # Required. The list of required status checks in order to merge into this branch +# contexts: [] +# # Required. Enforce all configured restrictions for administrators. Set to +# # true to enforce required status checks for repository administrators. +# # Set to null to disable. +# enforce_admins: true +# # Prevent merge commits from being pushed to matching branches +# required_linear_history: true +# # Required. Restrict who can push to this branch. Team and user +# # restrictions are only available for organization-owned repositories. Set +# # to null to disable. +# restrictions: +# apps: [] +# users: [] +# teams: [] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..403a918 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,83 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +name: CI + +on: + push: + branches: + - main + tags: + - "v*.*.*" + pull_request: + +permissions: + contents: write + packages: write + id-token: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.result.outputs.code }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check for code changes + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + code: + - '!docs/**' + - '!README.md' + - '!CHANGELOG.md' + - '!CONTRIBUTING.md' + - '!CODE_OF_CONDUCT.md' + - '!SECURITY.md' + - '!LICENSE.md' + - '!MAINTAINERS.md' + - '!.gitignore' + + - name: Determine result + id: result + run: | + # Always run for tag pushes (releases), otherwise use paths-filter result + if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then + echo "code=true" >> $GITHUB_OUTPUT + else + echo "code=${{ steps.filter.outputs.code }}" >> $GITHUB_OUTPUT + fi + + test: + name: Test + needs: + - changes + uses: ./.github/workflows/reusable-test-sdk.yaml + with: + image_repo: ghcr.io/agntcy + image_tag: ${{ github.sha }} + + success: + name: Success + if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'failure') }} + needs: + - changes + - test + runs-on: ubuntu-latest + steps: + - name: Echo Success + run: | + if [[ "${{ needs.changes.outputs.code }}" == "true" ]]; then + echo "::notice Success! All code checks passed." + else + echo "::notice Success! No code changes detected, skipped builds and tests." + fi diff --git a/.github/workflows/dependency.yml b/.github/workflows/dependency.yml index d144b8e..1794f7b 100644 --- a/.github/workflows/dependency.yml +++ b/.github/workflows/dependency.yml @@ -1,10 +1,11 @@ --- name: dependency -on: - # Review dependencies on pull requests to the default branch - pull_request: - branches: ["main"] +# TODO: enable dependency review after migration +# on: +# # Review dependencies on pull requests to the default branch +# pull_request: +# branches: ["main"] # Declare default permissions as read-only permissions: read-all diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c005ca..da85f2a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,13 +1,14 @@ --- name: lint -on: - push: - # Run superlinter on pushes to default branch - branches: - - main - # Run superlinter on pull request events - pull_request: +# TODO: enable linter after migration +# on: +# push: +# # Run superlinter on pushes to default branch +# branches: +# - main +# # Run superlinter on pull request events +# pull_request: # Declare default permissions as read-only permissions: read-all diff --git a/.github/workflows/reusable-test-sdk.yaml b/.github/workflows/reusable-test-sdk.yaml new file mode 100644 index 0000000..4a1db0e --- /dev/null +++ b/.github/workflows/reusable-test-sdk.yaml @@ -0,0 +1,53 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +name: Test SDK + +on: + workflow_call: + inputs: + image_repo: + required: true + type: string + description: "Image repo to use." + image_tag: + required: true + type: string + description: "Image tag to use." + +permissions: + id-token: write + contents: read + +jobs: + python: + name: Python + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Login to ghcr.io + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: ghcr.io + username: notused + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Task + uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 #v2.0.0 + + - name: Add dependencies for SDKs testing + env: + CLIENT_ID: "https://github.com/${{ github.repository }}/.github/workflows/reusable-test-sdk.yaml@${{ github.ref }}" + run: | + task sdk:deps:cicd:iodc-token-generation >> $GITHUB_ENV + + - name: Test Python SDK + env: + CHART_VERSION: "0.0.0-latest" + IMAGE_TAG: latest + run: | + task sdk:test:python diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a325117 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# IDEs +.vscode/ +.idea/ +.continue/ + +# Secrets +.env +.envrc + +# Builds +.bin/ + +# Python +dist/ +__pycache__/ +*.egg-info +*.pyc +*.pyo +*.pyd +.venv/ + +# JS +node_modules + +# Helm +install/charts/dir/charts/ +install/charts/dir/apiserver/charts/ +install/charts/dirctl/charts/ +install/charts/envoy-authz/charts/ + +# MacOS +.DS_Store + +# Test output +e2e/tmp/* + +# Coverage output +.coverage/ + +# Licensei +.licensei.cache + +tmp/ + +# AI +.cursor/ + +# Go workspace files +go.work +go.work.sum + +# MCP server binary +mcp/mcp-server + +# Local Build output +build/ +mcp/debug/ + +# Flutter GUI +gui/.dart_tool/ +gui/.flutter-plugins +gui/.flutter-plugins-dependencies +gui/build/ +gui/android/ +gui/ios/ +gui/linux/ +gui/web/ +gui/windows/ +gui/macos/ + +gui/.env diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ad21b..7054f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## 0.0.1 (2025-02-04) +[agntcy/dir]: https://github.com/agntcy/dir +[agntcy/dir-sdk-python]: https://github.com/agntcy/dir-sdk-python -### Feat +## 1.1.0 (2026-04-02) -- Add commitizen -- Initial Commit to create the scaffolding to template repo and relevant github actions +### Added + +The Directory Python SDK has been migrated from the [agntcy/dir][agntcy/dir] +repository to the [agntcy/dir-sdk-python][agntcy/dir-sdk-python] repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 794ed8c..74f9e06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # How to Contribute -Thanks for your interest in contributing to ``! Here are a few +Thanks for your interest in contributing to the Directory Python SDK! Here are a few general guidelines on contributing and reporting bugs that we ask you to review. Following these guidelines helps to communicate that you respect the time of the contributors managing and developing this open source project. In return, they @@ -18,7 +18,7 @@ in any real-time space e.g., Slack, Discord, etc. Before reporting a new issue, please ensure that the issue was not already reported or fixed by searching through our [issues -list](https://github.com/org_name/repo_name/issues). +list](https://github.com/agntcy/dir-sdk-python/issues). When creating a new issue, please be sure to include a **title and clear description**, as much relevant information as possible, and, if possible, a @@ -39,7 +39,7 @@ major version release. ## Other Ways to Contribute -We welcome anyone that wants to contribute to `` to triage and +We welcome anyone that wants to contribute to the Directory Python SDK to triage and reply to open issues to help troubleshoot and fix existing bugs. Here is what you can do: @@ -47,12 +47,12 @@ you can do: _[Reporting Issues](#reporting-issues)_ section, providing feedback to the issue's author on what might be missing. - Review and update the existing content of our - [Wiki](https://github.com/org_name/repo_name/wiki) with up-to-date + [Wiki](https://github.com/agntcy/dir-sdk-python/wiki) with up-to-date instructions and code samples. - Review existing pull requests, and testing patches against real existing - applications that use ``. + applications that use the Directory Python SDK. - Write a test, or add a missing test case to an existing test. -Thanks again for your interest on contributing to ``! +Thanks again for your interest on contributing to the Directory Python SDK! :heart: diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,176 @@ + 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 diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 422fdc0..6560f80 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,5 +1,8 @@ # Maintainers -- [GitHub Username](https://github.com/username) -- [GitHub Username](https://github.com/username) -- [GitHub Username](https://github.com/username) +- [ramizpolic](https://github.com/ramizpolic), Ramiz Polic +- [muscariello](https://github.com/muscariello), Luca Muscariello +- [paralta](https://github.com/paralta), Catarina Paralta +- [adamtagscherer](https://github.com/adamtagscherer), Adam Tagscherer +- [tkircsi](https://github.com/tkircsi), Tibor Kircsi +- [csirmazbendeguz](https://github.com/csirmazbendeguz), Bendegúz Csirmaz diff --git a/README.md b/README.md index fe2f9fe..e82d967 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,11 @@ -# Agntcy Repo Project Template - -[![Release](https://img.shields.io/github/v/release/agntcy/repo-template?display_name=tag)](CHANGELOG.md) -[![Lint](https://github.com/agntcy/repo-template/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/marketplace/actions/super-linter) -[![Contributor-Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-fbab2c.svg)](CODE_OF_CONDUCT.md) - -## Before You Start - -As much as possible, we have tried to provide enough tooling to get you up and -running quickly and with a minimum of effort. This includes sane defaults for -documentation; templates for bug reports, feature requests, and pull requests; -and [GitHub Actions](https://github.com/features/actions) that will -automatically manage stale issues and pull requests. This latter defaults to -labeling issues and pull requests as stale after 60 days of inactivity, and -closing them after 7 additional days of inactivity. These -[defaults](.github/workflows/stale.yml) and more can be configured. For -configuration options, please consult the documentation for the [stale -action](https://github.com/actions/stale). - -In trying to keep this template as generic and reusable as possible, there are -some things that were omitted out of necessity and others that need a little -tweaking. Before you begin developing in earnest, there are a few changes that -need to be made: - -- [ ] ✅ Select an [OSI-approved license](https://opensource.org/licenses) for - your project. This can easily be achieved through the 'Add File' button on the - GitHub UI, naming the file `LICENSE`, and selecting your desired license from - the provided list. -- [ ] Update the `` placeholder in this file to reflect the name - of the license you selected above. -- [ ] Replace `` in - [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) with a suitable communication - channel. -- [ ] Change references to `org_name` to the name of the org your repository belongs - to (eg. `agntcy`): - - [ ] In [`README.md`](README.md) - - [ ] In [`CONTRIBUTING.md`](CONTRIBUTING.md) -- [ ] Change references to `repo_name` to the name of your new repository: - - [ ] In [`README.md`](README.md) - - [ ] In [`CONTRIBUTING.md`](CONTRIBUTING.md) -- [ ] Update the Release and Lint `README` badges to point to your project URL. -- [ ] Update the links to `CONTRIBUTING.md` to point to your project URL: - - [ ] In - [`.github/ISSUE_TEMPLATE/bug_report.yml`](.github/ISSUE_TEMPLATE/bug_report.yml) - - [ ] In - [`.github/ISSUE_TEMPLATE/feature_request.yml`](.github/ISSUE_TEMPLATE/feature_request.yml) - - [ ] In - [`.github/pull_request_template.md`](.github/pull_request_template.md) -- [ ] Update the `Affected Version` tags in - [`.github/ISSUE_TEMPLATE/bug_report.yml`](.github/ISSUE_TEMPLATE/bug_report.yml) - if applicable. -- [ ] Replace the `` placeholder with the name of your project: - - [ ] In [`CONTRIBUTING.md`](CONTRIBUTING.md) - - [ ] In [`SECURITY.md`](SECURITY.md) -- [ ] Add names and contact information for the project maintainers to - [`MAINTAINERS.md`](MAINTAINERS.md). -- [ ] Update the `` placeholder in - [`.github/CODEOWNERS`](.github/CODEOWNERS) as well as the - `` and `` entries. -- [ ] Delete the release placeholder content in [`CHANGELOG.md`](CHANGELOG.md). - We encourage you to [keep a changelog](https://keepachangelog.com/en/1.0.0/). -- [ ] Configure [`.github/dependabot.yml`](.github/dependabot.yml) for your project's - language and tooling dependencies. -- [ ] In [`.github/settings.yml`](.github/settings.yml), update the following fields: - - [ ] `name`: Replace with the repository name for your project - - [ ] `description`: A short, 1-2 sentence description of your project - - [ ] `teams`: Uncomment and update the GitHub team names and permissions as appropriate - - [ ] `branches`: Uncomment and enable branch protection settings for your - project _(please **do not** disable branch protection entirely!)_ -- [ ] Replace the generic content in this file with the relevant details about - your project. -- [ ] 🚨 Delete this section of the `README`! +# Directory Python SDK + +[![Release](https://img.shields.io/github/v/release/agntcy/dir-sdk-python)](CHANGELOG.md) ## About The Project -Provide some information about what the project is/does. +The Directory Python SDK provides a simple way to interact with the Directory API. +It allows developers to integrate and use Directory functionality from their Python applications with ease. ## Getting Started @@ -81,41 +13,108 @@ To get a local copy up and running follow these simple steps. ### Prerequisites -This is an example of how to list things you need to use the software and how to -install them. - -- npm - - ```sh - npm install npm@latest -g - ``` +- Python 3.10 or higher +- Directory server instance +- [dirctl](https://github.com/agntcy/dir/releases) - Directory CLI binary ### Installation -1. Clone the repository - - ```sh - git clone https://github.com/org_name/repo_name.git - ``` - -2. Install npm packages - - ```sh - npm install - ``` +```sh +uv add agntcy-dir --index https://buf.build/gen/python +``` ## Usage -Use this space to show useful examples of how a project can be used. Additional -screenshots, code examples and demos work well in this space. You may also link -to more resources. - -_For more examples, please refer to the [Documentation](https://example.com) or -the [Wiki](https://github.com/org_name/repo_name/wiki)_ +### Configuration + +The SDK can be configured either via environment variables: + +```sh +# Environment variables (insecure mode, default) +export DIRECTORY_CLIENT_SERVER_ADDRESS="localhost:8888" +export DIRCTL_PATH="/path/to/dirctl" + +# Environment variables (X.509 authentication) +export DIRECTORY_CLIENT_SERVER_ADDRESS="localhost:8888" +export DIRECTORY_CLIENT_AUTH_MODE="x509" +export DIRECTORY_CLIENT_SPIFFE_SOCKET_PATH="/tmp/agent.sock" + +# Environment variables (JWT authentication) +export DIRECTORY_CLIENT_SERVER_ADDRESS="localhost:8888" +export DIRECTORY_CLIENT_AUTH_MODE="jwt" +export DIRECTORY_CLIENT_SPIFFE_SOCKET_PATH="/tmp/agent.sock" +export DIRECTORY_CLIENT_JWT_AUDIENCE="spiffe://example.org/dir-server" +``` + +Or via a `Config` object: + +```python +from agntcy.dir_sdk.client import Config, Client + +# Insecure mode (default, for development only) +config = Config( + server_address="localhost:8888", + dirctl_path="/usr/local/bin/dirctl" +) +client = Client(config) + +# X.509 authentication with SPIRE +x509_config = Config( + server_address="localhost:8888", + dirctl_path="/usr/local/bin/dirctl", + spiffe_socket_path="/tmp/agent.sock", + auth_mode="x509" +) +x509_client = Client(x509_config) + +# JWT authentication with SPIRE +jwt_config = Config( + server_address="localhost:8888", + dirctl_path="/usr/local/bin/dirctl", + spiffe_socket_path="/tmp/agent.sock", + auth_mode="jwt", + jwt_audience="spiffe://example.org/dir-server" +) +jwt_client = Client(jwt_config) +``` + +### Error Handling + +The SDK primarily raises `grpc.RpcError` exceptions for gRPC communication issues and `RuntimeError` for configuration problems: + +```python +import grpc +from agntcy.dir_sdk.client import Client + +try: + client = Client() + records = client.list(list_request) +except grpc.RpcError as e: + # Handle gRPC errors + if e.code() == grpc.StatusCode.NOT_FOUND: + print("Resource not found") + elif e.code() == grpc.StatusCode.UNAVAILABLE: + print("Server unavailable") + else: + print(f"gRPC error: {e.details()}") +except RuntimeError as e: + # Handle configuration or subprocess errors + print(f"Runtime error: {e}") +``` + +Common gRPC status codes: +- `NOT_FOUND`: Resource doesn't exist +- `ALREADY_EXISTS`: Resource already exists +- `UNAVAILABLE`: Server is down or unreachable +- `PERMISSION_DENIED`: Authentication/authorization failure +- `INVALID_ARGUMENT`: Invalid request parameters + +_For more examples, please refer to the [Documentation](https://docs.agntcy.org/dir/directory-sdk/#python-sdk) or +the [Wiki](https://github.com/agntcy/dir-sdk-python/wiki)_ ## Roadmap -See the [open issues](https://github.com/org_name/repo_name/issues) for a list +See the [open issues](https://github.com/agntcy/dir-sdk-python/issues) for a list of proposed features (and known issues). ## Contributing @@ -127,17 +126,10 @@ appreciated**. For detailed contributing guidelines, please see ## License -Distributed under the `` License. See [LICENSE](LICENSE) for more +Distributed under the Apache 2.0 License. See [LICENSE](LICENSE) for more information. ## Contact -Your Name - [@github_handle](https://github.com/github_handle) - email - Project Link: -[https://github.com/org_name/repo_name](https://github.com/org_name/repo_name) - -## Acknowledgements - -This template was adapted from -[https://github.com/othneildrew/Best-README-Template](https://github.com/othneildrew/Best-README-Template). +[https://github.com/agntcy/dir-sdk-python](https://github.com/agntcy/dir-sdk-python) diff --git a/SECURITY.md b/SECURITY.md index aca6d58..20b911f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,7 @@ # Security Policies and Procedures This document outlines security procedures and general policies for the -`` project. +Directory Python SDK project. - [Security Policies and Procedures](#security-policies-and-procedures) - [Disclosing a security issue](#disclosing-a-security-issue) @@ -10,12 +10,12 @@ This document outlines security procedures and general policies for the ## Disclosing a security issue -The `` maintainers take all security issues in the project -seriously. Thank you for improving the security of ``. We +The Directory Python SDK maintainers take all security issues in the project +seriously. Thank you for improving the security of the Directory Python SDK. We appreciate your dedication to responsible disclosure and will make every effort to acknowledge your contributions. -`` leverages GitHub's private vulnerability reporting. +The Directory Python SDK leverages GitHub's private vulnerability reporting. To learn more about this feature and how to submit a vulnerability report, review [GitHub's documentation on private reporting](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability). diff --git a/Taskfile.deps.yml b/Taskfile.deps.yml new file mode 100644 index 0000000..358532a --- /dev/null +++ b/Taskfile.deps.yml @@ -0,0 +1,95 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +version: "3" + +tasks: + default: + cmd: echo "Run the main Taskfile instead of this one." + + deps:bin-dir: + desc: Create bin directory + run: once + cmd: mkdir -p {{.BIN_DIR}} + status: + - test -d {{.BIN_DIR}} + + deps:helm: + desc: Ensure supported Helm version is installed + internal: true + deps: + - deps:bin-dir + preconditions: + - which curl + - which tar + cmds: + - cmd: echo "Downloading Helm v{{.HELM_VERSION}}..." + - cmd: curl -sSfL 'https://get.helm.sh/helm-v{{.HELM_VERSION}}-{{OS}}-{{ARCH}}.tar.gz' --output - | tar xzvOf - '{{OS}}-{{ARCH}}/helm' > {{.HELM_BIN}} + - cmd: chmod +x {{.HELM_BIN}} + status: + - test -x {{.HELM_BIN}} + + deps:kubectl: + desc: Ensure supported kubectl version is installed + internal: true + deps: + - deps:bin-dir + preconditions: + - which curl + cmds: + - cmd: echo "Downloading Kubectl v{{.KUBECTL_VERSION}}..." + - cmd: curl -L "https://dl.k8s.io/release/v{{.KUBECTL_VERSION}}/bin/{{OS}}/{{ARCH}}/kubectl" -o {{.KUBECTL_BIN}} + - cmd: chmod +x {{.KUBECTL_BIN}} + status: + - test -x {{.KUBECTL_BIN}} + + deps:kind: + desc: Ensure supported kind version is installed + internal: true + deps: + - deps:bin-dir + preconditions: + - which go + cmds: + - cmd: echo "Downloading Kind v{{.KIND_VERSION}}..." + - cmd: GOBIN={{.BIN_DIR}} go install sigs.k8s.io/kind@v{{.KIND_VERSION}} + - cmd: mv {{.BIN_DIR}}/kind {{.KIND_BIN}} + status: + - test -x {{.KIND_BIN}} + + deps:uv: + desc: Install uv + internal: true + deps: + - deps:bin-dir + preconditions: + - which curl + env: + UV_INSTALL_DIR: "{{ .BIN_DIR }}" + cmds: + - curl -sfL https://astral.sh/uv/{{.UV_VERSION}}/install.sh | sh + - mv {{.BIN_DIR}}/uv {{.UV_BIN}} + - chmod +x {{.UV_BIN}} + - rm {{.BIN_DIR}}/uvx + status: + - test -x {{.UV_BIN}} + + deps:cosign: + desc: Install sigstore cosign + internal: true + cmds: + - curl -sfL https://github.com/sigstore/cosign/releases/download/v{{.COSIGN_VERSION}}/cosign-{{OS}}-{{ARCH}} -o {{.BIN_DIR}}/cosign-{{.COSIGN_VERSION}} + - chmod +x {{.BIN_DIR}}/cosign-{{.COSIGN_VERSION}} + status: + - test -x {{.COSIGN_BIN}} + + deps:dir-ctl: + desc: Install dir-ctl + internal: true + run: once + vars: + IMAGE_TAG: "{{ .IMAGE_TAG }}" + preconditions: + - which docker + cmds: + - docker image pull ghcr.io/agntcy/dir-ctl:{{.IMAGE_TAG}} diff --git a/Taskfile.test-env.yml b/Taskfile.test-env.yml new file mode 100644 index 0000000..6fdde36 --- /dev/null +++ b/Taskfile.test-env.yml @@ -0,0 +1,163 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +version: "3" + +tasks: + default: + cmd: echo "Run the main Taskfile instead of this one." + + test-env:kubernetes:setup-cluster: + desc: Create a kind cluster and load Docker images + deps: + - deps:helm + - deps:kubectl + - deps:kind + vars: + KIND_CREATE_OPTS: '{{ .KIND_CREATE_OPTS | default "" }}' + KIND_CLUSTER_CONFIG_PATH: + sh: mktemp + # Port mapping is defined in the format of "hostPort1:containerPort1,hostPort2:containerPort2". + # Container port should match the NodePort of the service defined in the Helm chart. + KIND_EXTRA_PORT_MAPPING: '{{ .KIND_EXTRA_PORT_MAPPING | default "" }}' + cmds: + # Create cluster config + - | + cat < {{.KIND_CLUSTER_CONFIG_PATH}} + kind: Cluster + apiVersion: kind.x-k8s.io/v1alpha4 + nodes: + - role: control-plane + {{ if .KIND_EXTRA_PORT_MAPPING }} + extraPortMappings: + {{ range .KIND_EXTRA_PORT_MAPPING | splitList "," }} + - hostPort: {{ index (splitList ":" .) 0 }} + containerPort: {{ index (splitList ":" .) 1 }} + listenAddress: "127.0.0.1" + protocol: TCP + {{ end }} + {{ end }} + EOF + + # Create cluster + - | + {{ .KIND_BIN }} create cluster {{ .KIND_CREATE_OPTS }} --name {{ .KIND_CLUSTER_NAME }} --config {{ .KIND_CLUSTER_CONFIG_PATH }} + {{ .KIND_BIN }} export kubeconfig --name {{ .KIND_CLUSTER_NAME }} + {{ .KUBECTL_BIN }} cluster-info + + test-env:kubernetes:local: + aliases: [deploy:local] + desc: Deploy a local Directory server in Kubernetes + deps: + - task: test-env:kubernetes:setup-cluster + vars: + KIND_EXTRA_PORT_MAPPING: "{{.API_HOSTPORT}}:{{.API_NODEPORT}},{{.METRICS_HOSTPORT}}:{{.METRICS_NODEPORT}}" + vars: + # Kind args + KIND_CLUSTER_NAME: '{{ .KIND_CLUSTER_NAME | default "sdk-py-test" }}' + KIND_CREATE_OPTS: '{{ .KIND_CREATE_OPTS | default "" }}' + # Helm args + HELM_NAMESPACE: '{{ .HELM_NAMESPACE | default "dir-server" }}' + # API config + API_HOSTPORT: '{{ .API_HOSTPORT | default "8888" }}' + API_NODEPORT: '{{ .API_NODEPORT | default "30088" }}' + METRICS_HOSTPORT: '{{ .METRICS_HOSTPORT | default "9090" }}' + METRICS_NODEPORT: '{{ .METRICS_NODEPORT | default "30090" }}' + cmds: + # TODO: make logic idempotent so that running functional tests does not change previous contexts + + # Generate credentials and htpasswd file (using defaults) + - task: test-env:kubernetes:gen-htpasswd-creds + + # Cleanup credentials on exit (using defaults) + - defer: + task: test-env:kubernetes:cleanup-htpasswd-creds + + # Deploy chart + - | + # Load credentials + source /tmp/dir-htpasswd-creds.env + + {{ .HELM_BIN }} install dir oci://ghcr.io/agntcy/dir/helm-charts/dir \ + --version="{{ .CHART_VERSION }}" \ + --set apiserver.image.tag="{{ .IMAGE_TAG }}" \ + --set apiserver.image.pullSecrets="" \ + --set apiserver.service.type="NodePort" \ + --set apiserver.service.nodePort="{{.API_NODEPORT}}" \ + --set apiserver.service.metricsNodePort="{{.METRICS_NODEPORT}}" \ + {{ if .DIRECTORY_SERVER_PUBLICATION_SCHEDULER_INTERVAL }}--set apiserver.config.publication.scheduler_interval="{{ .DIRECTORY_SERVER_PUBLICATION_SCHEDULER_INTERVAL }}"{{ end }} \ + {{ if .DIRECTORY_SERVER_NAMING_TTL }}--set apiserver.config.naming.ttl="{{ .DIRECTORY_SERVER_NAMING_TTL }}"{{ end }} \ + --set apiserver.config.routing.refresh_interval="1s" \ + --set apiserver.secrets.ociAuth.username="${HTPASSWD_USERNAME}" \ + --set apiserver.secrets.ociAuth.password="${HTPASSWD_PASSWORD}" \ + --set apiserver.zot.authHeader="${HTPASSWD_AUTH_HEADER}" \ + --set-file apiserver.zot.secretFiles.htpasswd="/tmp/dir-htpasswd" \ + --set apiserver.secrets.syncAuth.username="${HTPASSWD_SYNC_USERNAME}" \ + --set apiserver.secrets.syncAuth.password="${HTPASSWD_SYNC_PASSWORD}" \ + {{ if .DIRECTORY_SERVER_RATELIMIT_ENABLED }}--set apiserver.config.ratelimit.enabled="{{ .DIRECTORY_SERVER_RATELIMIT_ENABLED }}"{{ end }} \ + {{ if .DIRECTORY_SERVER_RATELIMIT_GLOBAL_RPS }}--set apiserver.config.ratelimit.global_rps="{{ .DIRECTORY_SERVER_RATELIMIT_GLOBAL_RPS }}"{{ end }} \ + {{ if .DIRECTORY_SERVER_RATELIMIT_GLOBAL_BURST }}--set apiserver.config.ratelimit.global_burst="{{ .DIRECTORY_SERVER_RATELIMIT_GLOBAL_BURST }}"{{ end }} \ + {{ if .DIRECTORY_SERVER_RATELIMIT_PER_CLIENT_RPS }}--set apiserver.config.ratelimit.per_client_rps="{{ .DIRECTORY_SERVER_RATELIMIT_PER_CLIENT_RPS }}"{{ end }} \ + {{ if .DIRECTORY_SERVER_RATELIMIT_PER_CLIENT_BURST }}--set apiserver.config.ratelimit.per_client_burst="{{ .DIRECTORY_SERVER_RATELIMIT_PER_CLIENT_BURST }}"{{ end }} \ + {{ if .DIRECTORY_SERVER_OASF_API_VALIDATION_SCHEMA_URL }}--set apiserver.config.oasf_api_validation.schema_url="{{ .DIRECTORY_SERVER_OASF_API_VALIDATION_SCHEMA_URL }}"{{ end }} \ + --set apiserver.reconciler.image.tag="{{ .IMAGE_TAG }}" \ + --set apiserver.reconciler.config.regsync.interval="10s" \ + --set apiserver.reconciler.config.indexer.interval="10s" \ + --set apiserver.reconciler.config.name.interval="5s" \ + --set apiserver.reconciler.config.signature.interval="5s" \ + --namespace {{ .HELM_NAMESPACE }} \ + --create-namespace \ + --wait \ + --wait-for-jobs \ + --timeout "15m" + + test-env:kubernetes:local:cleanup: + desc: Cleanup Kubernetes environment for local deployment + deps: + - deps:kind + vars: + KIND_CLUSTER_NAME: '{{ .KIND_CLUSTER_NAME | default "sdk-py-test" }}' + cmds: + - "{{ .KIND_BIN }} delete cluster --name {{ .KIND_CLUSTER_NAME }}" + + test-env:kubernetes:gen-htpasswd-creds: + desc: Generate htpasswd credentials and files for DIR deployment + vars: + HTPASSWD_USERNAME: '{{ .HTPASSWD_USERNAME | default "apiserver" }}' + HTPASSWD_SYNC_USERNAME: '{{ .HTPASSWD_SYNC_USERNAME | default "sync-user" }}' + CREDS_FILE: '{{ .CREDS_FILE | default "/tmp/dir-htpasswd-creds.env" }}' + HTPASSWD_FILE: '{{ .HTPASSWD_FILE | default "/tmp/dir-htpasswd" }}' + cmds: + - | + # Generate fresh passwords + HTPASSWD_PASSWORD="$(openssl rand -hex 16)" + HTPASSWD_SYNC_PASSWORD="$(openssl rand -hex 16)" + + # Generate derived values + HTPASSWD_AUTH_HEADER="$(echo -n "{{ .HTPASSWD_USERNAME }}:${HTPASSWD_PASSWORD}" | base64)" + HTPASSWD_ADMIN="$(htpasswd -nbB "{{ .HTPASSWD_USERNAME }}" "${HTPASSWD_PASSWORD}" | sed 's/^{{ .HTPASSWD_USERNAME }}://')" + HTPASSWD_SYNC="$(htpasswd -nbB "{{ .HTPASSWD_SYNC_USERNAME }}" "${HTPASSWD_SYNC_PASSWORD}" | sed 's/^{{ .HTPASSWD_SYNC_USERNAME }}://')" + + # Write credentials to env file (for helm --set commands) + cat > {{ .CREDS_FILE }} << EOF + export HTPASSWD_USERNAME="{{ .HTPASSWD_USERNAME }}" + export HTPASSWD_PASSWORD="${HTPASSWD_PASSWORD}" + export HTPASSWD_AUTH_HEADER="${HTPASSWD_AUTH_HEADER}" + export HTPASSWD_SYNC_USERNAME="{{ .HTPASSWD_SYNC_USERNAME }}" + export HTPASSWD_SYNC_PASSWORD="${HTPASSWD_SYNC_PASSWORD}" + EOF + + # Write htpasswd file (for helm --set-file commands) + cat > {{ .HTPASSWD_FILE }} << EOF + {{ .HTPASSWD_USERNAME }}:${HTPASSWD_ADMIN} + {{ .HTPASSWD_SYNC_USERNAME }}:${HTPASSWD_SYNC} + EOF + + test-env:kubernetes:cleanup-htpasswd-creds: + desc: Cleanup htpasswd credentials and files + vars: + CREDS_FILE: '{{ .CREDS_FILE | default "/tmp/dir-htpasswd-creds.env" }}' + HTPASSWD_FILE: '{{ .HTPASSWD_FILE | default "/tmp/dir-htpasswd" }}' + cmds: + - rm -f {{ .CREDS_FILE }} + - rm -f {{ .HTPASSWD_FILE }} diff --git a/Taskfile.vars.yml b/Taskfile.vars.yml new file mode 100644 index 0000000..1fe5d0c --- /dev/null +++ b/Taskfile.vars.yml @@ -0,0 +1,20 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +version: "3" + +vars: + CHART_VERSION: '{{ .CHART_VERSION | default "0.0.0-latest" }}' + IMAGE_TAG: '{{ .IMAGE_TAG | default "latest" }}' + BIN_DIR: "{{ .ROOT_DIR }}/.bin" + HELM_VERSION: "4.1.3" + HELM_BIN: "{{ .BIN_DIR }}/helm-{{.HELM_VERSION}}" + KUBECTL_VERSION: "1.35.3" + KUBECTL_BIN: "{{ .BIN_DIR }}/kubectl-{{.KUBECTL_VERSION}}" + KIND_VERSION: "0.31.0" + KIND_BIN: "{{ .BIN_DIR }}/kind-{{.KIND_VERSION}}" + UV_VERSION: "0.10.11" + UV_BIN: "{{ .BIN_DIR }}/uv-{{.UV_VERSION}}" + COSIGN_VERSION: "3.0.5" + COSIGN_BIN: "{{ .BIN_DIR }}/cosign-{{.COSIGN_VERSION}}" + KIND_CLUSTER_NAME: "sdk-py-test" diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..d2006b2 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,114 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +version: "3" + +includes: + vars: + taskfile: Taskfile.vars.yml + flatten: true + excludes: [default] + + deps: + taskfile: Taskfile.deps.yml + dir: "{{.BIN_DIR}}" + flatten: true + excludes: [default] + + test-env: + taskfile: Taskfile.test-env.yml + flatten: true + excludes: [default] + +tasks: + sdk:deps:cicd:iodc-token-generation: + desc: Get Fulcio OIDC token for CICD + requires: + vars: [CLIENT_ID] + cmds: + - | + OIDC_TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sigstore" | jq -r '.value') + + CLIENT_ID="{{.CLIENT_ID}}" + PROVIDER_URL="https://token.actions.githubusercontent.com" + + echo "OIDC_PROVIDER_URL=${PROVIDER_URL}" + echo "CLIENT_ID=${CLIENT_ID}" + echo "OIDC_TOKEN=${OIDC_TOKEN}" + + sdk:build:python: + desc: Build python client SDK package + dir: ./dir-sdk-python + deps: + - task: sdk:deps:python + cmds: + - "{{.UV_BIN}} build" + + sdk:test-env:create: + desc: Create Kubernetes cluster test environment + cmds: + - task: test-env:kubernetes:local + vars: + DIRECTORY_SERVER_PUBLICATION_SCHEDULER_INTERVAL: 1s + DIRECTORY_SERVER_OASF_API_VALIDATION_SCHEMA_URL: "https://schema.oasf.outshift.com" + + sdk:test-env:delete: + desc: Delete Kubernetes cluster test environment + cmds: + - task: test-env:kubernetes:local:cleanup + + sdk:test:python: + desc: Test python client SDK package + dir: ./dir-sdk-python + deps: + - task: sdk:deps:python + cmds: + - defer: { task: sdk:test-env:delete } + - task: sdk:test-env:create + - | + export DIRCTL_IMAGE_TAG="{{ .IMAGE_TAG }}" + export COSIGN_PATH="$(printf "%s" "${COSIGN_PATH:-{{ .COSIGN_BIN }}}")" + + # Run SDK tests + '{{.UV_BIN}}' run pytest + + # Run SDK example to verify it works + # cd {{ .ROOT_DIR }}/sdk/examples/example-py + # '{{.UV_BIN}}' sync + # '{{.UV_BIN}}' run python example.py + + sdk:deps:python: + desc: Install deps for python SDK package + dir: ./dir-sdk-python + deps: + - task: deps:uv + - task: deps:cosign + - task: deps:dir-ctl + cmds: + - "{{.UV_BIN}} sync --all-packages" + + sdk:deps:python:example: + desc: Install deps for Python SDK example package + internal: true + dir: ./sdk/examples/example-py + deps: + - task: deps:uv + cmds: + - "{{.UV_BIN}} sync --all-packages" + + sdk:tidy: + desc: Install deps for SDK packages + cmds: + - task: sdk:deps:python + - task: sdk:deps:python:example + + sdk:release:python: + desc: Release python client SDK package + dir: ./dir-sdk-python + env: + UV_PUBLISH_TOKEN: "{{ .UV_PUBLISH_TOKEN }}" + deps: + - task: deps:uv + cmds: + - "{{.UV_BIN}} publish" diff --git a/dir-sdk-python/agntcy/dir_sdk/__init__.py b/dir-sdk-python/agntcy/dir_sdk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dir-sdk-python/agntcy/dir_sdk/client/__init__.py b/dir-sdk-python/agntcy/dir_sdk/client/__init__.py new file mode 100644 index 0000000..f1e7c48 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/client/__init__.py @@ -0,0 +1,5 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from .client import Client +from .config import Config diff --git a/dir-sdk-python/agntcy/dir_sdk/client/client.py b/dir-sdk-python/agntcy/dir_sdk/client/client.py new file mode 100644 index 0000000..0fd2078 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/client/client.py @@ -0,0 +1,1605 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +"""Client module for the AGNTCY Directory service. + +This module provides a high-level Python client for interacting with the AGNTCY +Directory services including routing, search, store, and signing operations. +""" + +import builtins +import logging +import os +import json +import subprocess +import tempfile +from collections.abc import Sequence + +import grpc +from google.protobuf import json_format +from cryptography.hazmat.primitives import serialization +from spiffe import WorkloadApiClient, X509Source + +from agntcy.dir_sdk.client.config import Config +from agntcy.dir_sdk.models import ( + core_v1, + events_v1, + routing_v1, + store_v1, + naming_v1, + sign_v1, + search_v1, +) + +logger = logging.getLogger("client") + + +class JWTAuthInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, grpc.StreamStreamClientInterceptor): + """gRPC interceptor that adds JWT-SVID authentication to requests.""" + + def __init__(self, socket_path: str, audience: str) -> None: + """Initialize the JWT auth interceptor. + + Args: + socket_path: Path to the SPIFFE Workload API socket + audience: JWT audience claim for token validation + + """ + self.socket_path = socket_path + self.audience = audience + self._workload_client = WorkloadApiClient(socket_path=socket_path) + + def _get_jwt_token(self) -> str: + """Fetch a JWT-SVID from the SPIRE Workload API. + + Returns: + JWT token string + + Raises: + RuntimeError: If unable to fetch JWT-SVID + + """ + try: + # Fetch JWT-SVID with the configured audience + jwt_svid = self._workload_client.fetch_jwt_svid(audience=[self.audience]) + if jwt_svid and jwt_svid.token: + return jwt_svid.token + msg = "Failed to fetch JWT-SVID: empty token" + raise RuntimeError(msg) + except Exception as e: + msg = f"Failed to fetch JWT-SVID: {e}" + raise RuntimeError(msg) from e + + def _add_jwt_metadata(self, client_call_details): + """Add JWT token to request metadata.""" + token = self._get_jwt_token() + metadata = [] + if client_call_details.metadata is not None: + metadata = list(client_call_details.metadata) + metadata.append(("authorization", f"Bearer {token}")) + + return grpc._interceptor._ClientCallDetails( + method=client_call_details.method, + timeout=client_call_details.timeout, + metadata=metadata, + credentials=client_call_details.credentials, + wait_for_ready=client_call_details.wait_for_ready, + compression=client_call_details.compression, + ) + + def intercept_unary_unary(self, continuation, client_call_details, request): + """Intercept unary-unary RPC calls.""" + new_details = self._add_jwt_metadata(client_call_details) + return continuation(new_details, request) + + def intercept_unary_stream(self, continuation, client_call_details, request): + """Intercept unary-stream RPC calls.""" + new_details = self._add_jwt_metadata(client_call_details) + return continuation(new_details, request) + + def intercept_stream_unary(self, continuation, client_call_details, request_iterator): + """Intercept stream-unary RPC calls.""" + new_details = self._add_jwt_metadata(client_call_details) + return continuation(new_details, request_iterator) + + def intercept_stream_stream(self, continuation, client_call_details, request_iterator): + """Intercept stream-stream RPC calls.""" + new_details = self._add_jwt_metadata(client_call_details) + return continuation(new_details, request_iterator) + + +class Client: + """High-level client for interacting with AGNTCY Directory services. + + This client provides a unified interface for operations across Dir API. + It handles gRPC communication and provides convenient methods for common operations. + + Example: + >>> config = Config.load_from_env() + >>> client = Client(config) + >>> # Use client for operations... + + """ + + def __init__(self, config: Config | None = None) -> None: + """Initialize the client with the given configuration. + + Args: + config: Optional client configuration. If None, loads from environment + variables using Config.load_from_env(). + + Raises: + grpc.RpcError: If unable to establish connection to the server + ValueError: If configuration is invalid + + """ + # Load config if unset + if config is None: + config = Config.load_from_env() + self.config = config + + # Create gRPC channel + channel = self.__create_grpc_channel() + + # Initialize service clients + self.store_client = store_v1.StoreServiceStub(channel) + self.routing_client = routing_v1.RoutingServiceStub(channel) + self.publication_client = routing_v1.PublicationServiceStub(channel) + self.search_client = search_v1.SearchServiceStub(channel) + self.sign_client = sign_v1.SignServiceStub(channel) + self.sync_client = store_v1.SyncServiceStub(channel) + self.event_client = events_v1.EventServiceStub(channel) + self.naming_client = naming_v1.NamingServiceStub(channel) + + def __create_grpc_channel(self) -> grpc.Channel: + # Handle different authentication modes + if self.config.auth_mode == "": + return grpc.insecure_channel(self.config.server_address) + elif self.config.auth_mode == "jwt": + return self.__create_jwt_channel() + elif self.config.auth_mode == "x509": + return self.__create_x509_channel() + elif self.config.auth_mode == "tls": + return self.__create_tls_channel() + else: + msg = f"Unsupported auth mode: {self.config.auth_mode}" + raise ValueError(msg) + + def __create_x509_channel(self) -> grpc.Channel: + """Create a secure gRPC channel using SPIFFE X.509.""" + if self.config.spiffe_socket_path == "": + msg = "SPIFFE socket path is required for X.509 authentication" + raise ValueError(msg) + + # Create secure gRPC channel using SPIFFE X.509 + workload_client = WorkloadApiClient(socket_path=self.config.spiffe_socket_path) + x509_src = X509Source( + workload_api_client=workload_client, + socket_path=self.config.spiffe_socket_path, + timeout_in_seconds=60, + ) + + root_ca = b"" + for b in x509_src.bundles: + for a in b.x509_authorities: + root_ca += a.public_bytes(encoding=serialization.Encoding.PEM) + + private_key = x509_src.svid.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + public_leaf = x509_src.svid.leaf.public_bytes( + encoding=serialization.Encoding.PEM + ) + + credentials = grpc.ssl_channel_credentials( + root_certificates=root_ca, + private_key=private_key, + certificate_chain=public_leaf, + ) + + channel = grpc.secure_channel( + target=self.config.server_address, + credentials=credentials, + ) + + return channel + + def __create_jwt_channel(self) -> grpc.Channel: + """Create a gRPC channel with JWT authentication.""" + if self.config.spiffe_socket_path == "": + msg = "SPIFFE socket path is required for JWT authentication" + raise ValueError(msg) + + if self.config.jwt_audience == "": + msg = "JWT audience is required for JWT authentication" + raise ValueError(msg) + + # Create X509Source to get the SPIFFE bundle for TLS verification + # In JWT mode, the server presents its X.509-SVID via TLS for transport security + # The X509Source will handle fetching the bundle from the Workload API + workload_client = WorkloadApiClient(socket_path=self.config.spiffe_socket_path) + x509_source = X509Source( + workload_api_client=workload_client, + socket_path=self.config.spiffe_socket_path, + timeout_in_seconds=60, + ) + + # Extract the CA certificates from all bundles + root_ca = b"" + for bundle in x509_source.bundles: + for authority in bundle.x509_authorities: + root_ca += authority.public_bytes(encoding=serialization.Encoding.PEM) + + if not root_ca: + msg = "Failed to fetch X.509 bundle from SPIRE: no bundles returned" + raise RuntimeError(msg) + + # Create JWT interceptor + jwt_interceptor = JWTAuthInterceptor( + socket_path=self.config.spiffe_socket_path, + audience=self.config.jwt_audience + ) + + # Create secure channel with JWT interceptor and TLS using SPIFFE bundle + # For JWT mode: Server presents X.509-SVID via TLS, clients authenticate with JWT-SVID + credentials = grpc.ssl_channel_credentials(root_certificates=root_ca) + channel = grpc.secure_channel( + target=self.config.server_address, + credentials=credentials, + ) + channel = grpc.intercept_channel(channel, jwt_interceptor) + + # Close the X509Source since we only needed it to get the bundle + x509_source.close() + + return channel + + def __create_tls_channel(self) -> grpc.Channel: + if not self.config.tls_ca_file: + msg = "TLS CA file is required for TLS authentication" + raise ValueError(msg) + if not self.config.tls_cert_file: + msg = "TLS certificate file is required for TLS authentication" + raise ValueError(msg) + if not self.config.tls_key_file: + msg = "TLS key file is required for TLS authentication" + raise ValueError(msg) + + try: + with open(self.config.tls_ca_file, "rb") as f: + root_ca = f.read() + with open(self.config.tls_cert_file, "rb") as f: + cert_chain = f.read() + with open(self.config.tls_key_file, "rb") as f: + private_key = f.read() + except OSError as e: + msg = f"Failed to read TLS files: {e}" + raise RuntimeError(msg) from e + + credentials = grpc.ssl_channel_credentials( + root_certificates=root_ca, + private_key=private_key, + certificate_chain=cert_chain, + ) + + channel = grpc.secure_channel( + target=self.config.server_address, + credentials=credentials, + ) + + return channel + + def publish( + self, + req: routing_v1.PublishRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> None: + """Publish objects to the Routing API matching the specified criteria. + + Makes the specified objects available for discovery and retrieval by other + clients in the network. The objects must already exist in the store before + they can be published. + + Args: + req: Publish request containing the query for the objects to publish + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the object is not found or cannot be published + + Example: + >>> ref = routing_v1.RecordRef(cid="QmExample123") + >>> req = routing_v1.PublishRequest(record_refs=[ref]) + >>> client.publish(req) + + """ + try: + self.routing_client.Publish(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during publish: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during publish: %s", e) + msg = f"Failed to publish object: {e}" + raise RuntimeError(msg) from e + + def list( + self, + req: routing_v1.ListRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> list[routing_v1.ListResponse]: + """List objects from the Routing API matching the specified criteria. + + Returns a list of objects that match the filtering and + query criteria specified in the request. + + Args: + req: List request specifying filtering criteria, pagination, etc. + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[routing_v1.ListResponse]: List of items matching the criteria + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the list operation fails + + Example: + >>> req = routing_v1.ListRequest(limit=10) + >>> responses = client.list(req) + >>> for response in responses: + ... print(f"Found object: {response.cid}") + + """ + results: list[routing_v1.ListResponse] = [] + + try: + stream = self.routing_client.List(req, metadata=metadata) + results.extend(stream) + except grpc.RpcError as e: + logger.exception("gRPC error during list: %s", e) + raise + except Exception as e: + logger.exception("Error receiving objects: %s", e) + msg = f"Failed to list objects: {e}" + raise RuntimeError(msg) from e + + return results + + def search_cids( + self, + req: search_v1.SearchCIDsRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[search_v1.SearchCIDsResponse]: + """Search for record CIDs matching the specified queries. + + Performs a search across the storage using the provided search queries + and returns a list of matching CIDs. This is efficient for lookups + where only the CIDs are needed. + + Args: + req: Search request containing queries, filters, and search options + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[search_v1.SearchCIDsResponse]: List of CIDs matching the queries + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the search operation fails + + Example: + >>> req = search_v1.SearchCIDsRequest(queries=[query], limit=10) + >>> responses = client.search_cids(req) + >>> for response in responses: + ... print(f"Found CID: {response.record_cid}") + + """ + results: list[search_v1.SearchCIDsResponse] = [] + + try: + stream = self.search_client.SearchCIDs(req, metadata=metadata) + results.extend(stream) + except grpc.RpcError as e: + logger.exception("gRPC error during search: %s", e) + raise + except Exception as e: + logger.exception("Error receiving search results: %s", e) + msg = f"Failed to search CIDs: {e}" + raise RuntimeError(msg) from e + + return results + + def search_records( + self, + req: search_v1.SearchRecordsRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[search_v1.SearchRecordsResponse]: + """Search for full records matching the specified queries. + + Performs a search across the storage using the provided search queries + and returns a list of full records with all metadata. + + Args: + req: Search request containing queries, filters, and search options + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[search_v1.SearchRecordsResponse]: List of records matching the queries + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the search operation fails + + Example: + >>> req = search_v1.SearchRecordsRequest(queries=[query], limit=10) + >>> responses = client.search_records(req) + >>> for response in responses: + ... print(f"Found: {response.record.name}") + + """ + results: list[search_v1.SearchRecordsResponse] = [] + + try: + stream = self.search_client.SearchRecords(req, metadata=metadata) + results.extend(stream) + except grpc.RpcError as e: + logger.exception("gRPC error during search: %s", e) + raise + except Exception as e: + logger.exception("Error receiving search results: %s", e) + msg = f"Failed to search records: {e}" + raise RuntimeError(msg) from e + + return results + + def unpublish( + self, + req: routing_v1.UnpublishRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> None: + """Unpublish objects from the Routing API matching the specified criteria. + + Removes the specified objects from the public network, making them no + longer discoverable by other clients. The objects remain in the local + store but are not available for network discovery. + + Args: + req: Unpublish request containing the query for the objects to unpublish + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the objects cannot be unpublished + + Example: + >>> ref = routing_v1.RecordRef(cid="QmExample123") + >>> req = routing_v1.UnpublishRequest(record_refs=[ref]) + >>> client.unpublish(req) + + """ + try: + self.routing_client.Unpublish(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during unpublish: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during unpublish: %s", e) + msg = f"Failed to unpublish object: {e}" + raise RuntimeError(msg) from e + + def push( + self, + records: builtins.list[core_v1.Record], + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[core_v1.RecordRef]: + """Push records to the Store API. + + Uploads one or more records to the content store, making them available + for retrieval and reference. Each record is assigned a unique content + identifier (CID) based on its content hash. + + Args: + records: List of Record objects to push to the store + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[core_v1.RecordRef]: List of objects containing the CIDs of the pushed records + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the push operation fails + + Example: + >>> records = [create_record("example")] + >>> refs = client.push(records) + >>> print(f"Pushed with CID: {refs[0].cid}") + + """ + results: list[core_v1.RecordRef] = [] + + try: + response = self.store_client.Push(iter(records), metadata=metadata) + results.extend(response) + except grpc.RpcError as e: + logger.exception("gRPC error during push: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during push: %s", e) + msg = f"Failed to push object: {e}" + raise RuntimeError(msg) from e + + return results + + def push_referrer( + self, + req: builtins.list[store_v1.PushReferrerRequest], + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[store_v1.PushReferrerResponse]: + """Push records with referrer metadata to the Store API. + + Uploads records along with optional artifacts and referrer information. + This is useful for pushing complex objects that include additional + metadata or associated artifacts. + + Args: + req: List of PushReferrerRequest objects containing records and + optional artifacts + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[store_v1.PushReferrerResponse]: List of objects containing the details of pushed artifacts + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the push operation fails + + Example: + >>> requests = [store_v1.PushReferrerRequest(record=record)] + >>> responses = client.push_referrer(requests) + + """ + results: list[store_v1.PushReferrerResponse] = [] + + try: + response = self.store_client.PushReferrer(iter(req), metadata=metadata) + results.extend(response) + except grpc.RpcError as e: + logger.exception("gRPC error during push_referrer: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during push_referrer: %s", e) + msg = f"Failed to push object: {e}" + raise RuntimeError(msg) from e + + return results + + def pull( + self, + refs: builtins.list[core_v1.RecordRef], + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[core_v1.Record]: + """Pull records from the Store API by their references. + + Retrieves one or more records from the content store using their + content identifiers (CIDs). + + Args: + refs: List of RecordRef objects containing the CIDs to retrieve + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[core_v1.Record]: List of record objects retrieved from the store + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the pull operation fails + + Example: + >>> refs = [core_v1.RecordRef(cid="QmExample123")] + >>> records = client.pull(refs) + >>> for record in records: + ... print(f"Retrieved record: {record}") + + """ + results: list[core_v1.Record] = [] + + try: + response = self.store_client.Pull(iter(refs), metadata=metadata) + results.extend(response) + except grpc.RpcError as e: + logger.exception("gRPC error during pull: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during pull: %s", e) + msg = f"Failed to pull object: {e}" + raise RuntimeError(msg) from e + + return results + + def pull_referrer( + self, + req: builtins.list[store_v1.PullReferrerRequest], + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[store_v1.PullReferrerResponse]: + """Pull records with referrer metadata from the Store API. + + Retrieves records along with their associated artifacts and referrer + information. This provides access to complex objects that include + additional metadata or associated artifacts. + + Args: + req: List of PullReferrerRequest objects containing records and + optional artifacts for pull operations + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[store_v1.PullReferrerResponse]: List of objects containing the retrieved records + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the pull operation fails + + Example: + >>> requests = [store_v1.PullReferrerRequest(ref=ref)] + >>> responses = client.pull_referrer(requests) + >>> for response in responses: + ... print(f"Retrieved: {response}") + + """ + results: list[store_v1.PullReferrerResponse] = [] + + try: + response = self.store_client.PullReferrer(iter(req), metadata=metadata) + results.extend(response) + except grpc.RpcError as e: + logger.exception("gRPC error during pull_referrer: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during pull_referrer: %s", e) + msg = f"Failed to pull referrer object: {e}" + raise RuntimeError(msg) from e + + return results + + def lookup( + self, + refs: builtins.list[core_v1.RecordRef], + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[core_v1.RecordMeta]: + """Look up metadata for records in the Store API. + + Retrieves metadata information for one or more records without + downloading the full record content. This is useful for checking + if records exist and getting basic information about them. + + Args: + refs: List of RecordRef objects containing the CIDs to look up + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List[core_v1.RecordMeta]: List of objects containing metadata for the records + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the lookup operation fails + + Example: + >>> refs = [core_v1.RecordRef(cid="QmExample123")] + >>> metadatas = client.lookup(refs) + >>> for meta in metadatas: + ... print(f"Record size: {meta.size}") + + """ + results: list[core_v1.RecordMeta] = [] + + try: + response = self.store_client.Lookup(iter(refs), metadata=metadata) + results.extend(response) + except grpc.RpcError as e: + logger.exception("gRPC error during lookup: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during lookup: %s", e) + msg = f"Failed to lookup object: {e}" + raise RuntimeError(msg) from e + + return results + + def delete( + self, + refs: builtins.list[core_v1.RecordRef], + metadata: Sequence[tuple[str, str]] | None = None, + ) -> None: + """Delete records from the Store API. + + Permanently removes one or more records from the content store using + their content identifiers (CIDs). This operation cannot be undone. + + Args: + refs: List of RecordRef objects containing the CIDs to delete + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the delete operation fails + + Example: + >>> refs = [core_v1.RecordRef(cid="QmExample123")] + >>> client.delete(refs) + + """ + try: + self.store_client.Delete(iter(refs), metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during delete: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during delete: %s", e) + msg = f"Failed to delete object: {e}" + raise RuntimeError(msg) from e + + def create_sync( + self, + req: store_v1.CreateSyncRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> store_v1.CreateSyncResponse: + """Create a new synchronization configuration. + + Creates a new sync configuration that defines how data should be + synchronized between different Directory servers. This allows for + automated data replication and consistency across multiple locations. + + Args: + req: CreateSyncRequest containing the sync configuration details + including source, target, and synchronization parameters + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + store_v1.CreateSyncResponse: Response containing the created sync details + including the sync ID and configuration + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the sync creation fails + + Example: + >>> req = store_v1.CreateSyncRequest() + >>> response = client.create_sync(req) + >>> print(f"Created sync with ID: {response.sync_id}") + + """ + try: + response = self.sync_client.CreateSync(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during create_sync: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during create_sync: %s", e) + msg = f"Failed to create sync: {e}" + raise RuntimeError(msg) from e + + return response + + def list_syncs( + self, + req: store_v1.ListSyncsRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[store_v1.ListSyncsItem]: + """List existing synchronization configurations. + + Retrieves a list of all sync configurations that have been created, + with optional filtering and pagination support. This allows you to + monitor and manage multiple synchronization processes. + + Args: + req: ListSyncsRequest containing filtering criteria, pagination options, + and other query parameters + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + list[store_v1.ListSyncsItem]: List of sync configuration items with + their details including ID, name, status, + and configuration parameters + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the list operation fails + + Example: + >>> req = store_v1.ListSyncsRequest(limit=10) + >>> syncs = client.list_syncs(req) + >>> for sync in syncs: + ... print(f"Sync: {sync}") + + """ + results: list[store_v1.ListSyncsItem] = [] + + try: + stream = self.sync_client.ListSyncs(req, metadata=metadata) + results.extend(stream) + except grpc.RpcError as e: + logger.exception("gRPC error during list_syncs: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during list_syncs: %s", e) + msg = f"Failed to list syncs: {e}" + raise RuntimeError(msg) from e + + return results + + def get_sync( + self, + req: store_v1.GetSyncRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> store_v1.GetSyncResponse: + """Retrieve detailed information about a specific synchronization configuration. + + Gets comprehensive details about a specific sync configuration including + its current status, configuration parameters, performance metrics, + and any recent errors or warnings. + + Args: + req: GetSyncRequest containing the sync ID or identifier to retrieve + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + store_v1.GetSyncResponse: Detailed information about the sync configuration + including status, metrics, configuration, and logs + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the get operation fails + + Example: + >>> req = store_v1.GetSyncRequest(sync_id="sync-123") + >>> response = client.get_sync(req) + >>> print(f"Sync status: {response.status}") + >>> print(f"Last update: {response.last_update_time}") + + """ + try: + response = self.sync_client.GetSync(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during get_sync: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during get_sync: %s", e) + msg = f"Failed to get sync: {e}" + raise RuntimeError(msg) from e + + return response + + def delete_sync( + self, + req: store_v1.DeleteSyncRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> None: + """Delete a synchronization configuration. + + Permanently removes a sync configuration and stops any ongoing + synchronization processes. This operation cannot be undone and + will halt all data synchronization for the specified configuration. + + Args: + req: DeleteSyncRequest containing the sync ID or identifier to delete + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the delete operation fails + + Example: + >>> req = store_v1.DeleteSyncRequest(sync_id="sync-123") + >>> client.delete_sync(req) + >>> print(f"Sync deleted") + + """ + try: + self.sync_client.DeleteSync(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during delete_sync: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during delete_sync: %s", e) + msg = f"Failed to delete sync: {e}" + raise RuntimeError(msg) from e + + def listen( + self, + req: events_v1.ListenRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> grpc.UnaryStreamMultiCallable: + """ + Listen establishes a streaming connection to receive events. + Events are only delivered while the stream is active. + On disconnect, missed events are not recoverable. + + Args: + req: ListenRequest specifies filters for event subscription. + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + A grpc stream which can read and closed. + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the listen operation fails + """ + + try: + stream = self.event_client.Listen(req, metadata=metadata) + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.CANCELLED: + logger.exception("gRPC listen stream was canceled: %s", e) + raise + else: + logger.exception("gRPC error during listen: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during listen: %s", e) + msg = f"Failed to listen: {e}" + raise RuntimeError(msg) from e + + return stream + + def create_publication( + self, + req: routing_v1.PublishRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> routing_v1.CreatePublicationResponse: + """ + Create publication creates a new publication request that will be processed by the PublicationWorker. + The publication request can specify either a query, a list of specific CIDs, + or all records to be announced to the DHT. + + Args: + req: PublishRequest specifies the record references and queries for publication. + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + CreatePublicationResponse returns the result of creating a publication request. + This includes the publication ID and any relevant metadata. + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the publication operation create fails + """ + try: + response = self.publication_client.CreatePublication(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during create_publication: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during create_publication: %s", e) + msg = f"Failed to create publication: {e}" + raise RuntimeError(msg) from e + + return response + + def get_publication( + self, + req: routing_v1.GetPublicationRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> routing_v1.GetPublicationResponse: + """ + GetPublication retrieves details of a specific publication request by its identifier. + This includes the current status and any associated metadata. + + Args: + req: GetPublicationRequest specifies which publication to retrieve by its identifier. + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + GetPublicationResponse contains the full details of a specific publication request. + Includes status, progress information, and any error details if applicable. + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the publication get operation fails + """ + try: + response = self.publication_client.GetPublication(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during get_publication: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during get_publication: %s", e) + msg = f"Failed to get publication: {e}" + raise RuntimeError(msg) from e + + return response + + def list_publication( + self, + req: routing_v1.ListPublicationsRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> builtins.list[routing_v1.ListPublicationsItem]: + """ + ListPublications returns a stream of all publication requests in the system. + This allows monitoring of pending, processing, and completed publication requests. + + Args: + req: ListPublicationsRequest contains optional filters for listing publication requests. + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + List of ListPublicationsItem represents a single publication request in the list response. + Contains publication details including ID, status, and creation timestamp. + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the publication list operation fails + """ + + results: list[routing_v1.ListPublicationsItem] = [] + + try: + stream = self.publication_client.ListPublications(req, metadata=metadata) + results.extend(stream) + except grpc.RpcError as e: + logger.exception("gRPC error during list_publication: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during list_publication: %s", e) + msg = f"Failed to list publication: {e}" + raise RuntimeError(msg) from e + + return results + + def resolve( + self, + name: str, + version: str | None = None, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> naming_v1.ResolveResponse: + """Resolve a record name to CIDs. + + Resolves a record reference (name with optional version) to content identifiers (CIDs). + When no version is specified, returns all versions sorted by creation time (newest first). + + Args: + name: The name of the record to resolve (e.g., "cisco.com/agent") + version: Optional version to resolve to (e.g., "v1.0.0") + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + naming_v1.ResolveResponse: Response containing the resolved record references + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the resolve operation fails + + Example: + >>> # Resolve latest version + >>> response = client.resolve("cisco.com/agent") + >>> print(f"Latest CID: {response.records[0].cid}") + >>> + >>> # Resolve specific version + >>> response = client.resolve("cisco.com/agent", "v1.0.0") + + """ + try: + req = naming_v1.ResolveRequest(name=name) + if version: + req.version = version + response = self.naming_client.Resolve(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during resolve: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during resolve: %s", e) + msg = f"Failed to resolve name: {e}" + raise RuntimeError(msg) from e + + return response + + def get_verification_info( + self, + cid: str | None = None, + name: str | None = None, + version: str | None = None, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> naming_v1.GetVerificationInfoResponse: + """Get verification info for a record. + + Retrieves the name verification status for a record. Can look up by CID directly + or by name (with optional version) which will be resolved first. + + Args: + cid: Optional CID of the record to check + name: Optional name of the record to check (e.g., "cisco.com/agent") + version: Optional version when looking up by name (e.g., "v1.0.0") + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + naming_v1.GetVerificationInfoResponse: Response containing verification status + + Raises: + grpc.RpcError: If the gRPC call fails (includes InvalidArgument, NotFound, etc.) + RuntimeError: If the operation fails + + Example: + >>> # Check by CID + >>> response = client.get_verification_info(cid="bafyreib...") + >>> + >>> # Check by name (latest version) + >>> response = client.get_verification_info(name="cisco.com/agent") + >>> + >>> # Check by name with specific version + >>> response = client.get_verification_info(name="cisco.com/agent", version="v1.0.0") + + """ + try: + req = naming_v1.GetVerificationInfoRequest() + if cid: + req.cid = cid + if name: + req.name = name + if version: + req.version = version + response = self.naming_client.GetVerificationInfo(req, metadata=metadata) + except grpc.RpcError as e: + logger.exception("gRPC error during get_verification_info: %s", e) + raise + except Exception as e: + logger.exception("Unexpected error during get_verification_info: %s", e) + msg = f"Failed to get verification info: {e}" + raise RuntimeError(msg) from e + + return response + + def verify( + self, + req: sign_v1.VerifyRequest, + metadata: Sequence[tuple[str, str]] | None = None, + ) -> sign_v1.VerifyResponse: + """Verify a cryptographic signature on a record. + + Validates the cryptographic signature of a previously signed record + to ensure its authenticity and integrity. This operation verifies + that the record has not been tampered with since signing. + + + The verification process uses the external dirctl command-line tool + to perform the actual cryptographic operations. + + When useServerVerification is true, uses the server's cached result (from the reconciler). + + Args: + req: VerifyRequest containing the record reference and verification + parameters. The provider can specify either key-based verification + (with a public key) or OIDC-based verification + metadata: Optional gRPC metadata headers as sequence of key-value pairs + + Returns: + VerifyResponse containing the verification result and details + + Raises: + RuntimeError: If the verification operation fails + + Example: + >>> req = sign_v1.VerifyRequest( + ... record_ref=core_v1.RecordRef(cid="QmExample123") + ... ) + >>> response = client.verify(req) + >>> print(f"Signature valid: {response.success}") + + """ + if req.from_server: + if req.record_ref is None or not req.record_ref.cid: + msg = "VerifyRequest.record_ref with cid is required" + raise RuntimeError(msg) + try: + resp = self.sign_client.Verify(req, metadata=metadata or ()) + return resp + except grpc.RpcError as e: + logger.exception("gRPC error during verify: %s", e) + raise RuntimeError(f"Verify failed: {e}") from e + except Exception as e: + logger.exception("Verification failed: %s", e) + raise RuntimeError(f"Verify failed: {e}") from e + + # Client-side verification via dirctl (same as main branch) + fd, output_path = tempfile.mkstemp(suffix=".json", prefix="dirctl-verify-") + os.close(fd) + _output_path = output_path + + if self.config.docker_config: + basename = os.path.basename(output_path) + self.config.docker_config.mounts.append(f"type=bind,src={output_path},dst=/{basename}") + _output_path = basename + + try: + provider: sign_v1.VerifyRequestProvider = req.provider + if provider is None: + self._verify_with_any(req.record_ref, None, _output_path) + elif provider.HasField("key"): + self._verify_with_key(req.record_ref, provider.key, _output_path) + elif provider.HasField("oidc"): + self._verify_with_oidc(req.record_ref, provider.oidc, _output_path) + elif provider.HasField("any"): + self._verify_with_any(req.record_ref, provider.any, _output_path) + else: + self._verify_with_any(req.record_ref, None, _output_path) + + # Read and parse the output file + with open(output_path, "rb") as f: + return self._parse_verify_response(f.read()) + except RuntimeError as e: + msg = f"Failed to verify the object: {e}" + raise RuntimeError(msg) from e + except Exception as e: + logger.exception("Verification operation failed: %s", e) + msg = f"Failed to verify the object: {e}" + raise RuntimeError(msg) from e + finally: + # Clean up the temp file + try: + os.unlink(output_path) + except OSError: + # Ignore cleanup errors + pass + + def _verify_with_key( + self, + record_ref: core_v1.RecordRef, + key_verifier: sign_v1.VerifyWithKey, + output_path: str, + ) -> None: + """Verify a record using a public key. + + This private method handles key-based verification by passing the public key + reference to the dirctl command. The key can be a file path, URL, or KMS URI. + + Args: + record_ref: Reference to the record to verify + key_verifier: VerifyWithKey containing the public key reference + output_path: Path to the output file for verification result + + Raises: + RuntimeError: If any error occurs during verification + + """ + try: + # Build and execute the verification command + # The key reference is passed directly to dirctl which handles + # file paths, URLs, KMS URIs, etc. + command = [ + *self.config.get_dirctl(), + "verify", + record_ref.cid, + "--key", + key_verifier.public_key, + "--output-file", + output_path, + ] + + subprocess.run( + command, + check=True, + capture_output=True, + timeout=60, # 1 minute timeout + ) + + except subprocess.CalledProcessError as e: + msg = f"dirctl verification failed with return code {e.returncode}: {e.stderr.decode('utf-8', errors='ignore')}" + raise RuntimeError(msg) from e + except subprocess.TimeoutExpired as e: + msg = "dirctl verification timed out" + raise RuntimeError(msg) from e + except Exception as e: + msg = f"Unexpected error during key-based verification: {e}" + raise RuntimeError(msg) from e + + def _verify_with_any( + self, + record_ref: core_v1.RecordRef, + any_verifier: sign_v1.VerifyWithAny | None, + output_path: str, + ) -> None: + """Verify a record using any valid signature. + + This private method handles verification of any signature on the record, + with optional OIDC options for OIDC-based signatures. + + Args: + record_ref: Reference to the record to verify + any_verifier: VerifyWithAny containing optional OIDC verification options, + or None for default verification + output_path: Path to the output file for verification result + + Raises: + RuntimeError: If any error occurs during verification + + """ + try: + # Build base command + command = [*self.config.get_dirctl(), "verify", record_ref.cid, "--output-file", output_path] + + # Add OIDC verification options if present + if any_verifier is not None and any_verifier.HasField("oidc_options"): + opts = any_verifier.oidc_options + if opts.tuf_mirror_url: + command.extend(["--tuf-mirror-url", opts.tuf_mirror_url]) + if opts.trusted_root_path: + command.extend(["--trusted-root-path", opts.trusted_root_path]) + if opts.ignore_tlog: + command.append("--ignore-tlog") + if opts.ignore_tsa: + command.append("--ignore-tsa") + if opts.ignore_sct: + command.append("--ignore-sct") + + # Execute the verification command + subprocess.run( + command, + check=True, + capture_output=True, + timeout=60, # 1 minute timeout + ) + + except subprocess.CalledProcessError as e: + msg = f"dirctl verification failed with return code {e.returncode}: {e.stderr.decode('utf-8', errors='ignore')}" + raise RuntimeError(msg) from e + except subprocess.TimeoutExpired as e: + msg = "dirctl verification timed out" + raise RuntimeError(msg) from e + except Exception as e: + msg = f"Unexpected error during verification: {e}" + raise RuntimeError(msg) from e + + def _verify_with_oidc( + self, + record_ref: core_v1.RecordRef, + oidc_verifier: sign_v1.VerifyWithOIDC | None, + output_path: str, + ) -> None: + """Verify a record using OIDC-based verification. + + This private method handles OIDC-based verification by building the appropriate + dirctl command with OIDC parameters and executing it. + + Args: + record_ref: Reference to the record to verify + oidc_verifier: VerifyWithOIDC containing the OIDC verification options, + or None for default verification + output_path: Path to the output file for verification result + + Raises: + RuntimeError: If any error occurs during verification + + """ + try: + # Build base command + command = [*self.config.get_dirctl(), "verify", record_ref.cid, "--output-file", output_path] + + # Add OIDC-specific parameters if provided + if oidc_verifier is not None: + if oidc_verifier.issuer: + command.extend(["--oidc-issuer", oidc_verifier.issuer]) + if oidc_verifier.subject: + command.extend(["--oidc-subject", oidc_verifier.subject]) + + # Add verification options if present + if oidc_verifier.HasField("options"): + opts = oidc_verifier.options + if opts.tuf_mirror_url: + command.extend(["--tuf-mirror-url", opts.tuf_mirror_url]) + if opts.trusted_root_path: + command.extend(["--trusted-root-path", opts.trusted_root_path]) + if opts.ignore_tlog: + command.append("--ignore-tlog") + if opts.ignore_tsa: + command.append("--ignore-tsa") + if opts.ignore_sct: + command.append("--ignore-sct") + + # Execute the verification command + subprocess.run( + command, + check=True, + capture_output=True, + timeout=60, # 1 minute timeout + ) + + except subprocess.CalledProcessError as e: + msg = f"dirctl verification failed with return code {e.returncode}: {e.stderr.decode('utf-8', errors='ignore')}" + raise RuntimeError(msg) from e + except subprocess.TimeoutExpired as e: + msg = "dirctl verification timed out" + raise RuntimeError(msg) from e + except Exception as e: + msg = f"Unexpected error during OIDC verification: {e}" + raise RuntimeError(msg) from e + + def _parse_verify_response(self, output: bytes) -> sign_v1.VerifyResponse: + """Parse the JSON output from dirctl verify command. + + Args: + output: Raw bytes output from the dirctl command + + Returns: + VerifyResponse parsed from the JSON output + + Raises: + RuntimeError: If the output cannot be parsed + + """ + try: + json_str = output.decode("utf-8") + json_data = json.loads(json_str) + + # The CLI outputs the response directly as JSON + response = sign_v1.VerifyResponse() + json_format.ParseDict(json_data, response) + return response + except (json.JSONDecodeError, UnicodeDecodeError) as e: + msg = f"Failed to parse verification response: {e}" + raise RuntimeError(msg) from e + + def sign( + self, + req: sign_v1.SignRequest, + ) -> None: + """Sign a record with a cryptographic signature. + + Creates a cryptographic signature for a record using either a private + key or OIDC-based signing. The signing process uses the external dirctl + command-line tool to perform the actual cryptographic operations. + + Args: + req: SignRequest containing the record reference and signing provider + configuration. The provider can specify either key-based signing + (with a private key) or OIDC-based signing + oidc_client_id: OIDC client identifier for OIDC-based signing. + Defaults to "sigstore" + + Raises: + RuntimeError: If the signing operation fails + + Example: + >>> req = sign_v1.SignRequest( + ... record_ref=core_v1.RecordRef(cid="QmExample123"), + ... provider=sign_v1.SignProvider(key=key_config) + ... ) + >>> client.sign(req) + >>> print(f"Signing completed!") + + """ + try: + if req.provider is None: + msg = "No signing provider specified in the request" + raise RuntimeError(msg) + elif req.provider.HasField("key"): + self._sign_with_key(req.record_ref, req.provider.key) + elif req.provider.HasField("oidc"): + self._sign_with_oidc(req.record_ref, req.provider.oidc) + except RuntimeError as e: + msg = f"Failed to sign the object: {e}" + raise RuntimeError(msg) from e + except Exception as e: + logger.exception("Signing operation failed: %s", e) + msg = f"Failed to sign the object: {e}" + raise RuntimeError(msg) from e + + def _sign_with_key( + self, + record_ref: core_v1.RecordRef, + key_signer: sign_v1.SignWithKey, + ) -> None: + """Sign a record using a private key. + + This private method handles key-based signing by passing the key reference + directly to the dirctl command. The key can be a file path, URL, or KMS URI. + + Args: + record_ref: Reference to the record to sign + key_signer: SignWithKey containing the private key reference and password + + Raises: + RuntimeError: If any error occurs during signing + + """ + try: + # Set up environment with password + # Always set COSIGN_PASSWORD (even if empty) to avoid terminal prompts + shell_env = os.environ.copy() + password = "" + if key_signer.password: + password = key_signer.password.decode("utf-8") + shell_env["COSIGN_PASSWORD"] = password + + # Build and execute the signing command + # The key reference is passed directly to dirctl which handles + # file paths, URLs, KMS URIs, etc. + command = [ + *self.config.get_dirctl(), + "sign", + record_ref.cid, + "--key", + key_signer.private_key, + ] + + subprocess.run( + command, + check=True, + capture_output=True, + env=shell_env, + timeout=60, # 1 minute timeout + ) + + except subprocess.CalledProcessError as e: + msg = f"dirctl signing failed with return code {e.returncode}: {e.stderr.decode('utf-8', errors='ignore')}" + raise RuntimeError(msg) from e + except subprocess.TimeoutExpired as e: + msg = "dirctl signing timed out" + raise RuntimeError(msg) from e + except Exception as e: + msg = f"Unexpected error during key-based signing: {e}" + raise RuntimeError(msg) from e + + def _sign_with_oidc( + self, + record_ref: core_v1.RecordRef, + oidc_signer: sign_v1.SignWithOIDC, + ) -> None: + """Sign a record using OIDC-based authentication. + + This private method handles OIDC-based signing by building the appropriate + dirctl command with OIDC parameters and executing it. + + Args: + req: SignRequest containing the record reference and OIDC provider + + Raises: + RuntimeError: If any other error occurs during signing + + """ + try: + shell_env = os.environ.copy() + + # Build base command + command = [*self.config.get_dirctl(), "sign", record_ref.cid] + + # Add OIDC-specific parameters + if oidc_signer.id_token: + command.extend(["--oidc-token", oidc_signer.id_token]) + if oidc_signer.options.oidc_provider_url: + command.extend(["--oidc-provider-url", oidc_signer.options.oidc_provider_url]) + if oidc_signer.options.oidc_client_id: + command.extend(["--oidc-client-id", oidc_signer.options.oidc_client_id]) + if oidc_signer.options.oidc_client_secret: + command.extend(["--oidc-client-secret", oidc_signer.options.oidc_client_secret]) + if oidc_signer.options.fulcio_url: + command.extend(["--fulcio-url", oidc_signer.options.fulcio_url]) + if oidc_signer.options.rekor_url: + command.extend(["--rekor-url", oidc_signer.options.rekor_url]) + if oidc_signer.options.timestamp_url: + command.extend(["--timestamp-url", oidc_signer.options.timestamp_url]) + if oidc_signer.options.skip_tlog: + command.append("--skip-tlog") + + # Execute the signing command + subprocess.run( + command, + check=True, + capture_output=True, + env=shell_env, + timeout=60, # 1 minute timeout + ) + + except subprocess.CalledProcessError as e: + msg = f"dirctl signing failed with return code {e.returncode}: {e.stderr.decode('utf-8', errors='ignore')}" + raise RuntimeError(msg) from e + except subprocess.TimeoutExpired as e: + msg = "dirctl signing timed out" + raise RuntimeError(msg) from e + except Exception as e: + msg = f"Unexpected error during OIDC signing: {e}" + raise RuntimeError(msg) from e diff --git a/dir-sdk-python/agntcy/dir_sdk/client/config.py b/dir-sdk-python/agntcy/dir_sdk/client/config.py new file mode 100644 index 0000000..dbd544a --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/client/config.py @@ -0,0 +1,155 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +import os + + +class DockerConfig: + """ + A class for using dirctl via the Docker image. + """ + + DEFAULT_DIRCTL_IMAGE = "ghcr.io/agntcy/dir-ctl" + DEFAULT_DIRCTL_IMAGE_TAG = "latest" + + def __init__( + self, + dirctl_image: str = DEFAULT_DIRCTL_IMAGE, + dirctl_image_tag: str = DEFAULT_DIRCTL_IMAGE_TAG, + envs: dict[str, str] = None, + mounts: list[str] = None, + user: str | None = None, + ) -> None: + if envs is None: + envs = {} + if mounts is None: + mounts = [] + + self.dirctl_image = dirctl_image + self.dirctl_image_tag = dirctl_image_tag + self.envs: dict[str, str] = envs + self.mounts: list[str] = mounts + self.user = user + + def get_commands(self) -> list[str]: + self.prune_mounts() + commands = ["docker", "container", "run", "--name=dir-ctl", "--rm", "--network", "host"] + if self.user: + commands.extend(["--user", self.user]) + for key, val in self.envs.items(): + commands.append("--env") + commands.append(f"{key}={val}") + for mount in self.mounts: + commands.append("--mount") + commands.append(mount) + commands.append(f"{self.dirctl_image}:{self.dirctl_image_tag}") + return commands + + def prune_mounts(self) -> None: + mounts = [] + for mount in self.mounts: + if mount.startswith("type=bind"): + type, src, dst = mount.split(",") + _, src = src.split("=") + if os.path.isfile(src): + mounts.append(mount) + self.mounts = mounts + + +class Config: + DEFAULT_SERVER_ADDRESS = "127.0.0.1:8888" + DEFAULT_DIRCTL_PATH = "dirctl" + DEFAULT_SPIFFE_SOCKET_PATH = "" + DEFAULT_AUTH_MODE = "" + DEFAULT_JWT_AUDIENCE = "" + DEFAULT_TLS_CA_FILE = "" + DEFAULT_TLS_CERT_FILE = "" + DEFAULT_TLS_KEY_FILE = "" + + def __init__( + self, + server_address: str = DEFAULT_SERVER_ADDRESS, + dirctl_path: str = DEFAULT_DIRCTL_PATH, + spiffe_socket_path: str = DEFAULT_SPIFFE_SOCKET_PATH, + auth_mode: str = DEFAULT_AUTH_MODE, + jwt_audience: str = DEFAULT_JWT_AUDIENCE, + tls_ca_file: str = DEFAULT_TLS_CA_FILE, + tls_cert_file: str = DEFAULT_TLS_CERT_FILE, + tls_key_file: str = DEFAULT_TLS_KEY_FILE, + docker_config: DockerConfig = None, + ) -> None: + self.server_address = server_address + self.dirctl_path = dirctl_path + self.spiffe_socket_path = spiffe_socket_path + self.auth_mode = auth_mode # '' for insecure, 'x509', 'jwt' or 'tls' + self.jwt_audience = jwt_audience + self.tls_ca_file = tls_ca_file + self.tls_cert_file = tls_cert_file + self.tls_key_file = tls_key_file + self.docker_config = docker_config + if dirctl_path and docker_config: + raise ValueError("You cannot specify both dirctl_path and docker_config.") + + def get_dirctl(self) -> list[str]: + if self.dirctl_path: + return [self.dirctl_path] + else: + return self.docker_config.get_commands() + + @staticmethod + def load_from_env(env_prefix: str = "DIRECTORY_CLIENT_") -> "Config": + """Load configuration from environment variables.""" + # Get dirctl path from environment variable without prefix + dirctl_path = os.environ.get("DIRCTL_PATH") + dirctl_image = os.environ.get("DIRCTL_IMAGE") + dirctl_image_tag = os.environ.get("DIRCTL_IMAGE_TAG") + + docker_config = None + if dirctl_image or dirctl_image_tag: + docker_config = DockerConfig( + dirctl_image or DockerConfig.DEFAULT_DIRCTL_IMAGE, + dirctl_image_tag or DockerConfig.DEFAULT_DIRCTL_IMAGE_TAG, + user="0:0", + ) + + # Use prefixed environment variables for other settings + server_address = os.environ.get( + f"{env_prefix}SERVER_ADDRESS", + Config.DEFAULT_SERVER_ADDRESS, + ) + spiffe_socket_path = os.environ.get( + f"{env_prefix}SPIFFE_SOCKET_PATH", + Config.DEFAULT_SPIFFE_SOCKET_PATH, + ) + auth_mode = os.environ.get( + f"{env_prefix}AUTH_MODE", + Config.DEFAULT_AUTH_MODE, + ) + jwt_audience = os.environ.get( + f"{env_prefix}JWT_AUDIENCE", + Config.DEFAULT_JWT_AUDIENCE, + ) + tls_ca_file = os.environ.get( + f"{env_prefix}TLS_CA_FILE", + Config.DEFAULT_TLS_CA_FILE, + ) + tls_cert_file = os.environ.get( + f"{env_prefix}TLS_CERT_FILE", + Config.DEFAULT_TLS_CERT_FILE, + ) + tls_key_file = os.environ.get( + f"{env_prefix}TLS_KEY_FILE", + Config.DEFAULT_TLS_KEY_FILE, + ) + + return Config( + server_address=server_address, + dirctl_path=dirctl_path, + spiffe_socket_path=spiffe_socket_path, + auth_mode=auth_mode, + jwt_audience=jwt_audience, + tls_ca_file=tls_ca_file, + tls_cert_file=tls_cert_file, + tls_key_file=tls_key_file, + docker_config=docker_config, + ) diff --git a/dir-sdk-python/agntcy/dir_sdk/client/test_client.py b/dir-sdk-python/agntcy/dir_sdk/client/test_client.py new file mode 100644 index 0000000..40582bb --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/client/test_client.py @@ -0,0 +1,568 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 +import os +import pathlib +import subprocess +import time +import threading +import unittest +import uuid + +import grpc + +from agntcy.dir_sdk.client import Client +from agntcy.dir_sdk.models import * + + +class TestClient(unittest.TestCase): + def setUp(self) -> None: + # Initialize the client + self.client = Client() + + def test_push(self) -> None: + records = self.gen_records(2, "push") + record_refs = self.client.push(records=records) + + assert record_refs is not None + assert isinstance(record_refs, list) + assert len(record_refs) == 2 + + for ref in record_refs: + assert isinstance(ref, core_v1.RecordRef) + assert len(ref.cid) == 59 + + def test_pull(self) -> None: + records = self.gen_records(2, "pull") + record_refs = self.client.push(records=records) + pulled_records = self.client.pull(refs=record_refs) + + assert pulled_records is not None + assert isinstance(pulled_records, list) + assert len(pulled_records) == 2 + + for index, record in enumerate(pulled_records): + assert isinstance(record, core_v1.Record) + assert records[index] == record + + def test_lookup(self) -> None: + records = self.gen_records(2, "lookup") + record_refs = self.client.push(records=records) + metadatas = self.client.lookup(record_refs) + + assert metadatas is not None + assert isinstance(metadatas, list) + assert len(metadatas) == 2 + + for metadata in metadatas: + assert isinstance(metadata, core_v1.RecordMeta) + + def test_publish(self) -> None: + records = self.gen_records(1, "publish") + record_refs = self.client.push(records=records) + publish_request = routing_v1.PublishRequest( + record_refs=routing_v1.RecordRefs(refs=record_refs), + ) + + try: + self.client.publish(publish_request) + except Exception as e: + assert e is None + + def test_list(self) -> None: + records = self.gen_records(1, "list") + record_refs = self.client.push(records=records) + self.client.publish(routing_v1.PublishRequest( + record_refs=routing_v1.RecordRefs(refs=record_refs), + )) + + # Sleep to allow the publication to be indexed + time.sleep(5) + + # Query for records in the domain + list_query = routing_v1.RecordQuery( + type=routing_v1.RECORD_QUERY_TYPE_DOMAIN, + value="technology/networking", + ) + + list_request = routing_v1.ListRequest(queries=[list_query]) + objects = list(self.client.list(list_request)) + + assert objects is not None + assert len(objects) != 0 + + for o in objects: + assert isinstance(o, routing_v1.ListResponse) + + def test_search(self) -> None: + records = self.gen_records(1, "search") + _ = self.client.push(records=records) + + search_query = search_v1.RecordQuery( + type=search_v1.RECORD_QUERY_TYPE_SKILL_ID, + value="10201", + ) + + search_request = search_v1.SearchCIDsRequest(queries=[search_query], limit=2) + + objects = list(self.client.search_cids(search_request)) + + assert objects is not None + assert len(objects) > 0 + + for o in objects: + assert isinstance(o, search_v1.SearchCIDsResponse) + + def test_unpublish(self) -> None: + records = self.gen_records(1, "unpublish") + record_refs = self.client.push(records=records) + + publish_record_refs = routing_v1.RecordRefs(refs=record_refs) + _ = routing_v1.PublishRequest(record_refs=publish_record_refs) + unpublish_request = routing_v1.UnpublishRequest(record_refs=publish_record_refs) + + try: + self.client.unpublish(unpublish_request) + except Exception as e: + assert e is None + + def test_delete(self) -> None: + records = self.gen_records(1, "delete") + record_refs = self.client.push(records=records) + try: + self.client.delete(record_refs) + except Exception as e: + assert e is None + + def test_push_referrer(self) -> None: + records = self.gen_records(2, "push_referrer") + record_refs = self.client.push(records=records) + + try: + request = [ + store_v1.PushReferrerRequest( + record_ref=record_refs[0], + type=sign_v1.Signature.DESCRIPTOR.full_name, + data={ + "signature": "dGVzdC1zaWduYXR1cmU=", # base64 encoded "test-signature" + "annotations": { + "payload": "test-payload-data" + } + } + ), + store_v1.PushReferrerRequest( + record_ref=record_refs[1], + type=sign_v1.Signature.DESCRIPTOR.full_name, + data={ + "signature": "dGVzdC1zaWduYXR1cmU=", # base64 encoded "test-signature" + "annotations": { + "payload": "test-payload-data" + } + } + ), + ] + + response = self.client.push_referrer(req=request) + + assert response is not None + assert len(response) == 2 + + for r in response: + assert isinstance(r, store_v1.PushReferrerResponse) + + except Exception as e: + assert e is None + + def test_pull_referrer(self) -> None: + records = self.gen_records(2, "pull_referrer") + record_refs = self.client.push(records=records) + + # Push referrers to these records + request = [ + store_v1.PushReferrerRequest( + record_ref=record_refs[0], + type=sign_v1.Signature.DESCRIPTOR.full_name, + data={ + "signature": "dGVzdC1zaWduYXR1cmU=", # base64 encoded "test-signature" + "annotations": { + "payload": "test-payload-data" + } + } + ), + store_v1.PushReferrerRequest( + record_ref=record_refs[1], + type=sign_v1.Signature.DESCRIPTOR.full_name, + data={ + "signature": "dGVzdC1zaWduYXR1cmU=", # base64 encoded "test-signature" + "annotations": { + "payload": "test-payload-data" + } + } + ), + ] + response = self.client.push_referrer(req=request) + assert response is not None + assert len(response) == 2 + for r in response: + assert isinstance(r, store_v1.PushReferrerResponse) + + try: + request = [ + store_v1.PullReferrerRequest( + record_ref=record_refs[0], + referrer_type=sign_v1.Signature.DESCRIPTOR.full_name, + ), + store_v1.PullReferrerRequest( + record_ref=record_refs[1], + referrer_type=sign_v1.Signature.DESCRIPTOR.full_name, + ), + ] + + response = self.client.pull_referrer(req=request) + + assert response is not None + assert len(response) == 2 + + for r in response: + assert isinstance(r, store_v1.PullReferrerResponse) + except Exception as e: + assert "pull referrer not implemented" in str( + e, + ) # Delete when the service implemented + + # self.assertIsNone(e) # Uncomment when the service implemented + + def test_sign_and_verify(self) -> None: + records = self.gen_records(2, "sign_verify") + record_refs = self.client.push(records=records) + + # Remove existing cosign keys if any + try: + pathlib.Path("cosign.key").unlink() + pathlib.Path("cosign.pub").unlink() + except FileNotFoundError: + pass # Clean state found + + # Prepare cosign key pair + key_password = "testing-key" + + # Set environment variable for cosign password + shell_env = os.environ.copy() + shell_env["COSIGN_PASSWORD"] = key_password + + docker_config = self.client.config.docker_config + if docker_config: + docker_config.envs["COSIGN_PASSWORD"] = key_password + + # Generate a key pair using cosign + cosign_path = os.getenv("COSIGN_PATH", "cosign") + command = (cosign_path, "generate-key-pair") + subprocess.run(command, check=True, capture_output=True, env=shell_env) + + if docker_config: + cosign_key_path = pathlib.Path("cosign.key").absolute() + cosign_pub_path = pathlib.Path("cosign.pub").absolute() + docker_config.mounts.append(f"type=bind,src={cosign_key_path},dst=/cosign.key") + docker_config.mounts.append(f"type=bind,src={cosign_pub_path},dst=/cosign.pub") + + # Prepare Key signing request using file path reference + # The CLI will load the key from the file path directly + key_provider = sign_v1.SignWithKey( + private_key="cosign.key", + password=key_password.encode("utf-8"), + ) + + request_key_provider = sign_v1.SignRequestProvider(key=key_provider) + key_request = sign_v1.SignRequest( + record_ref=record_refs[0], + provider=request_key_provider, + ) + + # Prepare OIDC signing request + token = shell_env.get("OIDC_TOKEN", "") + provider_url = shell_env.get("OIDC_PROVIDER_URL", "") + client_id = shell_env.get("OIDC_CLIENT_ID", "sigstore") + + oidc_options = sign_v1.SignOptionsOIDC(oidc_provider_url=provider_url, oidc_client_id=client_id) + oidc_provider = sign_v1.SignWithOIDC(id_token=token, options=oidc_options) + request_oidc_provider = sign_v1.SignRequestProvider(oidc=oidc_provider) + oidc_request = sign_v1.SignRequest( + record_ref=record_refs[1], + provider=request_oidc_provider, + ) + + try: + # Sign and verify using Key signing + self.client.sign(key_request) + + # Sign and verify using OIDC signing if set + if token and provider_url: + self.client.sign(oidc_request) + else: + record_refs.pop() # NOTE: Drop the unsigned record if no OIDC tested + + # Verification is asynchronous (reconciler caches results). Wait for it to run. + time.sleep(8) + + verify_index = 0 + for ref in record_refs: + response = self.client.verify(sign_v1.VerifyRequest(record_ref=ref)) + + assert response.success is True + + # Verify that signers array is present and not empty + assert response.signers is not None + assert len(response.signers) > 0 + + # For the first record (key-signed), verify key signer info + if verify_index == 0: + signer = response.signers[0] + assert signer.HasField("key"), "Expected key signer info for key-signed record" + assert signer.key.public_key, "Expected public key in signer info" + assert signer.key.algorithm, "Expected algorithm in signer info" + + # For OIDC-signed record, verify OIDC signer info + if verify_index == 1 and token and provider_url: + signer = response.signers[0] + assert signer.HasField("oidc"), "Expected OIDC signer info for OIDC-signed record" + assert signer.oidc.issuer, "Expected issuer in OIDC signer info" + assert signer.oidc.subject, "Expected subject in OIDC signer info" + + # Response with from_server (cached) must match local verification + from_server_response = self.client.verify( + sign_v1.VerifyRequest(record_ref=ref, from_server=True) + ) + assert from_server_response.success == response.success + assert from_server_response.signers is not None + assert len(from_server_response.signers) == len(response.signers) + for i in range(len(response.signers)): + r_signer = response.signers[i] + s_signer = from_server_response.signers[i] + assert r_signer.WhichOneof("type") == s_signer.WhichOneof("type") + if r_signer.HasField("key") and s_signer.HasField("key"): + assert s_signer.key.public_key == r_signer.key.public_key + assert s_signer.key.algorithm == r_signer.key.algorithm + if r_signer.HasField("oidc") and s_signer.HasField("oidc"): + assert s_signer.oidc.issuer == r_signer.oidc.issuer + assert s_signer.oidc.subject == r_signer.oidc.subject + + verify_index += 1 + + except Exception as e: + assert e is None, f"CID: {record_refs[0].cid} password: {key_provider.password} private_key: {key_provider.private_key}" + finally: + pathlib.Path("cosign.key").unlink() + pathlib.Path("cosign.pub").unlink() + + # Test invalid sign request + invalid_request = sign_v1.SignRequest( + record_ref=core_v1.RecordRef(cid="invalid-cid"), + provider=request_key_provider, + ) + try: + self.client.sign(invalid_request) + except RuntimeError as e: + assert "Failed to sign the object" in str(e) + + def test_sync(self) -> None: + try: + create_request = store_v1.CreateSyncRequest( + remote_directory_url=os.getenv( + "DIRECTORY_SERVER_PEER1_ADDRESS", + "0.0.0.0:8891", + ), + ) + create_response = self.client.create_sync(create_request) + + try: + assert uuid.UUID(create_response.sync_id) + except ValueError: + msg = f"Not an UUID: {create_response.sync_id}" + raise ValueError(msg) + + list_request = store_v1.ListSyncsRequest() + list_response = self.client.list_syncs(list_request) + + for sync_item in list_response: + try: + assert isinstance(sync_item, store_v1.ListSyncsItem) + assert uuid.UUID(sync_item.sync_id) + except ValueError: + msg = f"Not an UUID: {sync_item.sync_id}" + raise ValueError(msg) + + get_request = store_v1.GetSyncRequest(sync_id=create_response.sync_id) + get_response = self.client.get_sync(get_request) + + assert isinstance(get_response, store_v1.GetSyncResponse) + assert get_response.sync_id == create_response.sync_id + + delete_request = store_v1.DeleteSyncRequest(sync_id=create_response.sync_id) + self.client.delete_sync(delete_request) + + except Exception as e: + assert e is None + + def test_listen(self) -> None: + listen_request = events_v1.ListenRequest() + listen_stream = self.client.listen(listen_request) + events = [] + + def cancel_stream(): + time.sleep(15) + listen_stream.cancel() + + def read_stream(): + try: + for response in listen_stream: + events.append(response) + except grpc.RpcError as e: + if e.code() != grpc.StatusCode.CANCELLED: + raise + except Exception as e: + msg = f"Failed to listen: {e}" + raise RuntimeError(msg) from e + + cancel_thread = threading.Thread(target=cancel_stream) + read_thread = threading.Thread(target=read_stream) + + cancel_thread.start() + read_thread.start() + + event_records = self.gen_records(10, "listen") + _ = self.client.push(records=event_records) + + cancel_thread.join() + + assert events is not None + assert len(events) > 0 + + for o in events: + assert isinstance(o, events_v1.ListenResponse) + + def test_publication(self) -> None: + records = self.gen_records(1, "publication") + record_refs = self.client.push(records=records) + + try: + create_request = routing_v1.PublishRequest( + record_refs=routing_v1.RecordRefs(refs=record_refs), + ) + + create_response = self.client.create_publication(create_request) + + try: + assert isinstance(create_response, routing_v1.CreatePublicationResponse) + except ValueError: + msg = f"Not a CreatePublicationResponse object." + raise ValueError(msg) + + list_request = routing_v1.ListPublicationsRequest(limit=3) + list_response = self.client.list_publication(list_request) + + for publication_item in list_response: + try: + assert isinstance(publication_item, routing_v1.ListPublicationsItem) + except ValueError: + msg = f"Not a ListPublicationsItem object." + raise ValueError(msg) + + get_request = routing_v1.GetPublicationRequest(publication_id=create_response.publication_id) + get_response = self.client.get_publication(get_request) + + assert isinstance(get_response, routing_v1.GetPublicationResponse) + assert get_response.publication_id == create_response.publication_id + except Exception as e: + assert e is None + + def test_resolve(self) -> None: + # Push a record using built-in generator + records = self.gen_records(1, "resolve") + record_name = records[0].data["name"] + record_version = records[0].data["version"] + + record_refs = self.client.push(records=records) + assert len(record_refs) == 1 + + # Resolve by name + resolve_response = self.client.resolve(name=record_name) + + assert resolve_response is not None + assert len(resolve_response.records) > 0 + assert resolve_response.records[0].cid == record_refs[0].cid + assert resolve_response.records[0].name == record_name + assert resolve_response.records[0].version == record_version + + # Resolve by name with version + resolve_with_version_response = self.client.resolve(name=record_name, version=record_version) + + assert resolve_with_version_response is not None + assert len(resolve_with_version_response.records) == 1 + assert resolve_with_version_response.records[0].cid == record_refs[0].cid + + def test_get_verification_info(self) -> None: + # Push a record + records = self.gen_records(1, "verification") + record_refs = self.client.push(records=records) + + # Get verification info by CID (record is not signed, so it should return unverified) + verify_response = self.client.get_verification_info(cid=record_refs[0].cid) + + assert verify_response is not None + # Unsigned records should return verified=false + assert verify_response.verified is False + assert verify_response.error_message is not None or verify_response.error_message == "" + + def gen_records(self, count: int, test_function_name: str) -> list[core_v1.Record]: + """ + Generate test records with unique names. + Schema: https://schema.oasf.outshift.com/0.7.0/objects/record + """ + records: list[core_v1.Record] = [ + core_v1.Record( + data={ + "name": f"agntcy-{test_function_name}-{index}-{str(uuid.uuid4())[:8]}", + "version": "v3.0.0", + "schema_version": "0.7.0", + "description": "Research agent for Cisco's marketing strategy.", + "authors": ["Cisco Systems"], + "created_at": "2025-03-19T17:06:37Z", + "skills": [ + { + "name": "natural_language_processing/natural_language_generation/text_completion", + "id": 10201 + }, + { + "name": "natural_language_processing/analytical_reasoning/problem_solving", + "id": 10702 + } + ], + "locators": [ + { + "type": "docker_image", + "url": "https://ghcr.io/agntcy/marketing-strategy" + } + ], + "domains": [ + { + "name": "technology/networking", + "id": 103 + } + ], + "modules": [] + } + ) + for index in range(count) + ] + + return records + + @staticmethod + def cancel_stream_after_delay(responses, delay_sec=5): + # Wait before cancelling to simulate some condition or timeout + time.sleep(delay_sec) + print("Cancelling the stream...") + responses.cancel() + + +if __name__ == "__main__": + unittest.main() diff --git a/dir-sdk-python/agntcy/dir_sdk/models/README.md b/dir-sdk-python/agntcy/dir_sdk/models/README.md new file mode 100644 index 0000000..d9adae1 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/README.md @@ -0,0 +1,13 @@ +# Directory SDK Models + +Directory models are distributed via `buf.build` and generated from Protocol Buffers definitions, +which can become cumbersome to import and use. + +This module simplifies the imports and usage of data models needed by Directory APIs. +It re-exports all the models from the generated code into dedicated namespaces so that they can be imported directly from this module. + +For example, instead of importing `RecordMeta` from the generated code, use: + +```python +from agntcy.dir_sdk.models.core_v1 import RecordMeta +``` diff --git a/dir-sdk-python/agntcy/dir_sdk/models/__init__.py b/dir-sdk-python/agntcy/dir_sdk/models/__init__.py new file mode 100644 index 0000000..61e216b --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/__init__.py @@ -0,0 +1,13 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +# Export all protobuf packages for easier module imports. +# The actual subpackages in agntcy_dir.models expose gRPC stubs. + +from . import core_v1 as core_v1 +from . import naming_v1 as naming_v1 +from . import routing_v1 as routing_v1 +from . import search_v1 as search_v1 +from . import sign_v1 as sign_v1 +from . import store_v1 as store_v1 +from . import events_v1 as events_v1 diff --git a/dir-sdk-python/agntcy/dir_sdk/models/core_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/core_v1.py new file mode 100644 index 0000000..83f34b6 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/core_v1.py @@ -0,0 +1,5 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.core.v1.record_pb2 import * +from agntcy.dir.core.v1.record_pb2_grpc import * diff --git a/dir-sdk-python/agntcy/dir_sdk/models/events_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/events_v1.py new file mode 100644 index 0000000..2b8a8a3 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/events_v1.py @@ -0,0 +1,5 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.events.v1.event_service_pb2 import * +from agntcy.dir.events.v1.event_service_pb2_grpc import * diff --git a/dir-sdk-python/agntcy/dir_sdk/models/naming_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/naming_v1.py new file mode 100644 index 0000000..9d2f56a --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/naming_v1.py @@ -0,0 +1,6 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.naming.v1.naming_service_pb2 import * +from agntcy.dir.naming.v1.naming_service_pb2_grpc import * +from agntcy.dir.naming.v1.name_verification_pb2 import * diff --git a/dir-sdk-python/agntcy/dir_sdk/models/routing_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/routing_v1.py new file mode 100644 index 0000000..16d1571 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/routing_v1.py @@ -0,0 +1,11 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.routing.v1.peer_pb2 import * +from agntcy.dir.routing.v1.peer_pb2_grpc import * +from agntcy.dir.routing.v1.publication_service_pb2 import * +from agntcy.dir.routing.v1.publication_service_pb2_grpc import * +from agntcy.dir.routing.v1.record_query_pb2 import * +from agntcy.dir.routing.v1.record_query_pb2_grpc import * +from agntcy.dir.routing.v1.routing_service_pb2 import * +from agntcy.dir.routing.v1.routing_service_pb2_grpc import * diff --git a/dir-sdk-python/agntcy/dir_sdk/models/search_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/search_v1.py new file mode 100644 index 0000000..03eaaab --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/search_v1.py @@ -0,0 +1,7 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.search.v1.record_query_pb2 import * +from agntcy.dir.search.v1.record_query_pb2_grpc import * +from agntcy.dir.search.v1.search_service_pb2 import * +from agntcy.dir.search.v1.search_service_pb2_grpc import * diff --git a/dir-sdk-python/agntcy/dir_sdk/models/sign_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/sign_v1.py new file mode 100644 index 0000000..891c013 --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/sign_v1.py @@ -0,0 +1,7 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.sign.v1.signature_pb2 import * +from agntcy.dir.sign.v1.public_key_pb2 import * +from agntcy.dir.sign.v1.sign_service_pb2 import * +from agntcy.dir.sign.v1.sign_service_pb2_grpc import * diff --git a/dir-sdk-python/agntcy/dir_sdk/models/store_v1.py b/dir-sdk-python/agntcy/dir_sdk/models/store_v1.py new file mode 100644 index 0000000..cd89b7e --- /dev/null +++ b/dir-sdk-python/agntcy/dir_sdk/models/store_v1.py @@ -0,0 +1,7 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from agntcy.dir.store.v1.store_service_pb2 import * +from agntcy.dir.store.v1.store_service_pb2_grpc import * +from agntcy.dir.store.v1.sync_service_pb2 import * +from agntcy.dir.store.v1.sync_service_pb2_grpc import * diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7d6e3d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "dir-sdk-python" +version = "1.1.0" +description = "Directory SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "agntcy-dir-grpc-python==1.78.0.1.20260319131759+f3a65f0dc151", + "agntcy-dir-protocolbuffers-python==34.0.0.1.20260319131759+f3a65f0dc151", + "grpcio>=1.78.0", + "pyasn1>=0.6.3", + "spiffe>=0.2.6", + "spiffe-tls>=0.3.1", +] + +[dependency-groups] +dev = ["pytest>=8.4.1", "uuid>=1.30"] + +[[tool.uv.index]] +url = "https://buf.build/gen/python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["dir-sdk-python/agntcy"] + +[tool.pytest.ini_options] +pythonpath = ["dir-sdk-python"] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["CPY001"] +ignore = [] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.flake8-copyright] +notice-rgx = ''' +(?i)# Copyright AGNTCY Contributors \(https:\/\/github\.com\/agntcy\) +(?i)# SPDX-License-Identifier: Apache-2\.0 +''' diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2e8d175 --- /dev/null +++ b/uv.lock @@ -0,0 +1,591 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "agntcy-dir-grpc-python" +version = "1.78.0.1.20260319131759+f3a65f0dc151" +source = { registry = "https://buf.build/gen/python" } +dependencies = [ + { name = "agntcy-dir-protocolbuffers-python" }, + { name = "bufbuild-protovalidate-grpc-python" }, + { name = "grpcio" }, +] +wheels = [ + { url = "https://buf.build/gen/python/agntcy-dir-grpc-python/agntcy_dir_grpc_python-1.78.0.1.20260319131759+f3a65f0dc151-py3-none-any.whl" }, +] + +[[package]] +name = "agntcy-dir-protocolbuffers-pyi" +version = "34.1.0.1.20260402102459+5ac48fdac2c9" +source = { registry = "https://buf.build/gen/python" } +dependencies = [ + { name = "bufbuild-protovalidate-protocolbuffers-pyi" }, + { name = "protobuf" }, + { name = "types-protobuf" }, +] +wheels = [ + { url = "https://buf.build/gen/python/agntcy-dir-protocolbuffers-pyi/agntcy_dir_protocolbuffers_pyi-34.1.0.1.20260402102459+5ac48fdac2c9-py3-none-any.whl" }, +] + +[[package]] +name = "agntcy-dir-protocolbuffers-python" +version = "34.0.0.1.20260319131759+f3a65f0dc151" +source = { registry = "https://buf.build/gen/python" } +dependencies = [ + { name = "agntcy-dir-protocolbuffers-pyi" }, + { name = "bufbuild-protovalidate-protocolbuffers-python" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://buf.build/gen/python/agntcy-dir-protocolbuffers-python/agntcy_dir_protocolbuffers_python-34.0.0.1.20260319131759+f3a65f0dc151-py3-none-any.whl" }, +] + +[[package]] +name = "bufbuild-protovalidate-grpc-python" +version = "1.78.0.1.20260209202127+80ab13bee0bf" +source = { registry = "https://buf.build/gen/python" } +dependencies = [ + { name = "bufbuild-protovalidate-protocolbuffers-python" }, + { name = "grpcio" }, +] +wheels = [ + { url = "https://buf.build/gen/python/bufbuild-protovalidate-grpc-python/bufbuild_protovalidate_grpc_python-1.78.0.1.20260209202127+80ab13bee0bf-py3-none-any.whl" }, +] + +[[package]] +name = "bufbuild-protovalidate-protocolbuffers-pyi" +version = "34.1.0.1.20260209202127+80ab13bee0bf" +source = { registry = "https://buf.build/gen/python" } +dependencies = [ + { name = "protobuf" }, + { name = "types-protobuf" }, +] +wheels = [ + { url = "https://buf.build/gen/python/bufbuild-protovalidate-protocolbuffers-pyi/bufbuild_protovalidate_protocolbuffers_pyi-34.1.0.1.20260209202127+80ab13bee0bf-py3-none-any.whl" }, +] + +[[package]] +name = "bufbuild-protovalidate-protocolbuffers-python" +version = "34.0.0.1.20260209202127+80ab13bee0bf" +source = { registry = "https://buf.build/gen/python" } +dependencies = [ + { name = "bufbuild-protovalidate-protocolbuffers-pyi" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://buf.build/gen/python/bufbuild-protovalidate-protocolbuffers-python/bufbuild_protovalidate_protocolbuffers_python-34.0.0.1.20260209202127+80ab13bee0bf-py3-none-any.whl" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[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 = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "dir-sdk-python" +version = "1.1.0" +source = { editable = "." } +dependencies = [ + { name = "agntcy-dir-grpc-python" }, + { name = "agntcy-dir-protocolbuffers-python" }, + { name = "grpcio" }, + { name = "pyasn1" }, + { name = "spiffe" }, + { name = "spiffe-tls" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "uuid" }, +] + +[package.metadata] +requires-dist = [ + { name = "agntcy-dir-grpc-python", specifier = "==1.78.0.1.20260319131759+f3a65f0dc151" }, + { name = "agntcy-dir-protocolbuffers-python", specifier = "==34.0.0.1.20260319131759+f3a65f0dc151" }, + { name = "grpcio", specifier = ">=1.78.0" }, + { name = "pyasn1", specifier = ">=0.6.3" }, + { name = "spiffe", specifier = ">=0.2.6" }, + { name = "spiffe-tls", specifier = ">=0.3.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.1" }, + { name = "uuid", specifier = ">=1.30" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "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 = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pem" +version = "23.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/86/16c0b6789816f8d53f2f208b5a090c9197da8a6dae4d490554bb1bedbb09/pem-23.1.0.tar.gz", hash = "sha256:06503ff2441a111f853ce4e8b9eb9d5fedb488ebdbf560115d3dd53a1b4afc73", size = 43796, upload-time = "2023-06-21T10:24:40.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/97/8299a481ae6c08494b5d53511e6a4746775d8a354c685c69d8796b2ed482/pem-23.1.0-py3-none-any.whl", hash = "sha256:78bbb1e75b737891350cb9499cbba31da5d59545f360f44163c0bc751cad55d3", size = 9195, upload-time = "2023-06-21T10:24:39.164Z" }, +] + +[[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 = "protobuf" +version = "7.34.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/00/04a2ab36b70a52d0356852979e08b44edde0435f2115dc66e25f2100f3ab/protobuf-7.34.0.tar.gz", hash = "sha256:3871a3df67c710aaf7bb8d214cc997342e63ceebd940c8c7fc65c9b3d697591a", size = 454726, upload-time = "2026-02-27T00:30:25.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/c4/6322ab5c8f279c4c358bc14eb8aefc0550b97222a39f04eb3c1af7a830fa/protobuf-7.34.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e329966799f2c271d5e05e236459fe1cbfdb8755aaa3b0914fa60947ddea408", size = 429248, upload-time = "2026-02-27T00:30:14.924Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/b029bbbc61e8937545da5b79aa405ab2d9cf307a728f8c9459ad60d7a481/protobuf-7.34.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:9d7a5005fb96f3c1e64f397f91500b0eb371b28da81296ae73a6b08a5b76cdd6", size = 325753, upload-time = "2026-02-27T00:30:17.247Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/09f02671eb75b251c5550a1c48e7b3d4b0623efd7c95a15a50f6f9fc1e2e/protobuf-7.34.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4a72a8ec94e7a9f7ef7fe818ed26d073305f347f8b3b5ba31e22f81fd85fca02", size = 340200, upload-time = "2026-02-27T00:30:18.672Z" }, + { url = "https://files.pythonhosted.org/packages/b5/57/89727baef7578897af5ed166735ceb315819f1c184da8c3441271dbcfde7/protobuf-7.34.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:964cf977e07f479c0697964e83deda72bcbc75c3badab506fb061b352d991b01", size = 324268, upload-time = "2026-02-27T00:30:20.088Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3e/38ff2ddee5cc946f575c9d8cc822e34bde205cf61acf8099ad88ef19d7d2/protobuf-7.34.0-cp310-abi3-win32.whl", hash = "sha256:f791ec509707a1d91bd02e07df157e75e4fb9fbdad12a81b7396201ec244e2e3", size = 426628, upload-time = "2026-02-27T00:30:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/cb/71/7c32eaf34a61a1bae1b62a2ac4ffe09b8d1bb0cf93ad505f42040023db89/protobuf-7.34.0-cp310-abi3-win_amd64.whl", hash = "sha256:9f9079f1dde4e32342ecbd1c118d76367090d4aaa19da78230c38101c5b3dd40", size = 437901, upload-time = "2026-02-27T00:30:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e7/14dc9366696dcb53a413449881743426ed289d687bcf3d5aee4726c32ebb/protobuf-7.34.0-py3-none-any.whl", hash = "sha256:e3b914dd77fa33fa06ab2baa97937746ab25695f389869afdf03e81f34e45dc7", size = 170716, upload-time = "2026-02-27T00:30:23.994Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "spiffe" +version = "0.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "grpcio" }, + { name = "pem" }, + { name = "protobuf" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/ea/1d95bbca2e75e76133e37c6e4fd3b5d57301f10665f546e0bfdabd55815d/spiffe-0.2.6.tar.gz", hash = "sha256:7dcd48f9a60cdb3866cf286bcf51a6c61698a0000a66cbacc705620f3667399a", size = 39365, upload-time = "2026-03-15T22:21:43.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/df/c21d087817be03d521f0686cc5b6280d09f622f173e45876ab18f2927abf/spiffe-0.2.6-py3-none-any.whl", hash = "sha256:fae794652f960662de43ad0b93286d52262615e1facfbbcce38c2637596e2c09", size = 57780, upload-time = "2026-03-15T22:21:42.042Z" }, +] + +[[package]] +name = "spiffe-tls" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "pyopenssl" }, + { name = "spiffe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/f4/9f5bd28c14404d791f9465a47cafdc8de6f499209c80d005a93a95e4c7e4/spiffe_tls-0.3.1.tar.gz", hash = "sha256:acf1999f420259999def5e9d8108090c46ddfdbf307deadd7da309156685690b", size = 21042, upload-time = "2026-03-15T22:36:59.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5a/ac1117b67f0c6268a6d2be943b051e9b24cd23d320ceafd525573da111e7/spiffe_tls-0.3.1-py3-none-any.whl", hash = "sha256:c7ea723548ff456d799c96c5a38d050b2f927a7afc00b114db30bdafd86de13c", size = 26795, upload-time = "2026-03-15T22:37:00.377Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20260221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uuid" +version = "1.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/63/f42f5aa951ebf2c8dac81f77a8edcc1c218640a2a35a03b9ff2d4aa64c3d/uuid-1.30.tar.gz", hash = "sha256:1f87cc004ac5120466f36c5beae48b4c48cc411968eed0eaecd3da82aa96193f", size = 5811, upload-time = "2007-05-26T11:13:24Z" }