From 4f773b58fa9932df661994ad0f120a0e353cda1e Mon Sep 17 00:00:00 2001 From: "chenxin.shayne" Date: Mon, 14 Sep 2026 16:39:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(sms):=20=E6=96=B0=E5=A2=9E=E5=9B=BD?= =?UTF-8?q?=E5=86=85=E7=9F=AD=E4=BF=A1=20MCP=20Server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-sms.yml | 52 + README.md | 2 + server/mcp_server_sms/.env.example | 14 + server/mcp_server_sms/.gitignore | 9 + server/mcp_server_sms/LICENSE | 202 ++ server/mcp_server_sms/README.md | 115 + server/mcp_server_sms/README_zh.md | 134 ++ server/mcp_server_sms/pyproject.toml | 35 + .../src/mcp_server_sms/__init__.py | 5 + .../mcp_server_sms/src/mcp_server_sms/auth.py | 90 + .../src/mcp_server_sms/config.py | 67 + .../src/mcp_server_sms/core/__init__.py | 1 + .../mcp_server_sms/core/action_contracts.py | 480 ++++ .../src/mcp_server_sms/core/analytics.py | 548 +++++ .../src/mcp_server_sms/core/api_client.py | 88 + .../src/mcp_server_sms/core/api_protocol.py | 835 +++++++ .../src/mcp_server_sms/core/qualification.py | 842 +++++++ .../core/qualification_upload.py | 1332 ++++++++++ .../src/mcp_server_sms/core/workflows.py | 2143 +++++++++++++++++ .../src/mcp_server_sms/inputs.py | 147 ++ .../src/mcp_server_sms/runtime.py | 606 +++++ .../src/mcp_server_sms/server.py | 607 +++++ .../src/mcp_server_sms/store.py | 253 ++ .../mcp_server_sms/src/mcp_server_sms/web.py | 154 ++ server/mcp_server_sms/tests/conftest.py | 141 ++ .../mcp_server_sms/tests/desktop_launcher.py | 63 + .../tests/find_send_candidate.py | 353 +++ server/mcp_server_sms/tests/live_readonly.py | 242 ++ server/mcp_server_sms/tests/test_auth.py | 74 + server/mcp_server_sms/tests/test_client.py | 144 ++ server/mcp_server_sms/tests/test_inputs.py | 113 + .../mcp_server_sms/tests/test_operations.py | 335 +++ .../mcp_server_sms/tests/test_postgresql.py | 51 + .../tests/test_qualification.py | 302 +++ server/mcp_server_sms/tests/test_store.py | 83 + server/mcp_server_sms/tests/test_transport.py | 106 + server/mcp_server_sms/uv.lock | 1586 ++++++++++++ 37 files changed, 12354 insertions(+) create mode 100644 .github/workflows/test-sms.yml create mode 100644 server/mcp_server_sms/.env.example create mode 100644 server/mcp_server_sms/.gitignore create mode 100644 server/mcp_server_sms/LICENSE create mode 100644 server/mcp_server_sms/README.md create mode 100644 server/mcp_server_sms/README_zh.md create mode 100644 server/mcp_server_sms/pyproject.toml create mode 100644 server/mcp_server_sms/src/mcp_server_sms/__init__.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/auth.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/config.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/__init__.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/action_contracts.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/analytics.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/api_client.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/api_protocol.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/qualification.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/qualification_upload.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/core/workflows.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/inputs.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/runtime.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/server.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/store.py create mode 100644 server/mcp_server_sms/src/mcp_server_sms/web.py create mode 100644 server/mcp_server_sms/tests/conftest.py create mode 100644 server/mcp_server_sms/tests/desktop_launcher.py create mode 100644 server/mcp_server_sms/tests/find_send_candidate.py create mode 100644 server/mcp_server_sms/tests/live_readonly.py create mode 100644 server/mcp_server_sms/tests/test_auth.py create mode 100644 server/mcp_server_sms/tests/test_client.py create mode 100644 server/mcp_server_sms/tests/test_inputs.py create mode 100644 server/mcp_server_sms/tests/test_operations.py create mode 100644 server/mcp_server_sms/tests/test_postgresql.py create mode 100644 server/mcp_server_sms/tests/test_qualification.py create mode 100644 server/mcp_server_sms/tests/test_store.py create mode 100644 server/mcp_server_sms/tests/test_transport.py create mode 100644 server/mcp_server_sms/uv.lock diff --git a/.github/workflows/test-sms.yml b/.github/workflows/test-sms.yml new file mode 100644 index 00000000..e4473a79 --- /dev/null +++ b/.github/workflows/test-sms.yml @@ -0,0 +1,52 @@ +name: Test SMS MCP server + +on: + pull_request: + paths: + - "server/mcp_server_sms/**" + - ".github/workflows/test-sms.yml" + push: + branches: [main] + paths: + - "server/mcp_server_sms/**" + - ".github/workflows/test-sms.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.11", "3.12"] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: sms_test + POSTGRES_PASSWORD: sms-mcp-test-only + POSTGRES_DB: sms_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U sms_test -d sms_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install + run: python -m pip install -e './server/mcp_server_sms[test]' + - name: Check and test + working-directory: server/mcp_server_sms + env: + SMS_MCP_TEST_DATABASE_URL: postgresql+psycopg://sms_test:sms-mcp-test-only@localhost:5432/sms_test + run: | + ruff check src tests --select F,E9 + pytest -q + python -m build + python -m twine check dist/* diff --git a/README.md b/README.md index 3ef2ffb6..bbc88f65 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ MCP Server列表 与 **[火山引擎大模型生态广场](https://www.volcengin ### **企业服务与云通信** +- **[短信 SMS MCP](https://github.com/volcengine/mcp-server/tree/main/server/mcp_server_sms)**:通过独立工具完成国内短信资质、签名、模板申请、发送、普通群发与回执查询,支持受控文件输入和 Stateless MCP。 + - **[证书中心 MCP](https://github.com/volcengine/mcp-server/tree/main/server/mcp_server_certificate_center)**:通过自然语言驱动管理证书服务。 - **[域名服务 MCP](https://github.com/volcengine/mcp-server/tree/main/server/mcp_server_domain_service)**:通过自然语言高效查询并注册域名。 - **[TrafficRoute MCP](https://github.com/volcengine/mcp-server/tree/main/server/mcp_server_traffic_route)**:各种类型的 DNS 节点链路配置。 diff --git a/server/mcp_server_sms/.env.example b/server/mcp_server_sms/.env.example new file mode 100644 index 00000000..358c82b6 --- /dev/null +++ b/server/mcp_server_sms/.env.example @@ -0,0 +1,14 @@ +SMS_MCP_DATABASE_URL= +SMS_MCP_ENCRYPTION_KEY= +SMS_MCP_FLOW_TTL=1800 +SMS_MCP_OPERATION_TTL=86400 +# HTTP transport only; supplied by the deployment platform +SMS_MCP_PUBLIC_URL= +SMS_MCP_OIDC_ISSUER= +SMS_MCP_TOKEN_AUDIENCE= +# stdio only; input root is optional until a file tool is used +SMS_MCP_LOCAL_SUBJECT= +SMS_MCP_INPUT_ROOT= +VOLCENGINE_ACCESS_KEY= +VOLCENGINE_SECRET_KEY= +VOLCENGINE_SESSION_TOKEN= diff --git a/server/mcp_server_sms/.gitignore b/server/mcp_server_sms/.gitignore new file mode 100644 index 00000000..9a459aaa --- /dev/null +++ b/server/mcp_server_sms/.gitignore @@ -0,0 +1,9 @@ +.venv/ +.env +__pycache__/ +.pytest_cache/ +.ruff_cache/ +dist/ +build/ +*.db +*.db-* diff --git a/server/mcp_server_sms/LICENSE b/server/mcp_server_sms/LICENSE new file mode 100644 index 00000000..0203c9c9 --- /dev/null +++ b/server/mcp_server_sms/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but not + limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server/mcp_server_sms/README.md b/server/mcp_server_sms/README.md new file mode 100644 index 00000000..90b62d99 --- /dev/null +++ b/server/mcp_server_sms/README.md @@ -0,0 +1,115 @@ +# Volcengine SMS MCP Server + +[简体中文](README_zh.md) + +Independent tools for domestic SMS qualifications, signatures, templates, sending, ordinary batch tasks and delivery results. Business rules are adapted from the official Skill at `master@c7c49836dff727ca7a2ab297dc9d1ad62c5aec15` (1.4.4), without changing the SMS backend. + +Supports **MCP 2026-07-28**, stdio and stateless Streamable HTTP with the MCP Python SDK 2.x. Package version: **0.1.0**. Package, protocol and SMS API versions are independent. + +## Capabilities + +- Check service availability and query message groups. Only `RE:0001` means the service has not been enabled. Customers accept the service agreement in the existing SMS console themselves. +- Query qualification requirements; create and update drafts; upload and recognize documents; save customer-reviewed information; check enterprise/person information; verify mobile numbers when required; preview, submit and query applications. +- Query, preview and apply for signatures and templates; match approved templates and validate exact variables before sending. +- Send domestic SMS and query delivery results. Ordinary notification/marketing batches support CSV validation, creation, separate launch confirmation, cancellation and status queries. +- Report customer-visible send counts, delivery statistics and failure reasons. + +Special notification batches, OTP batches, international SMS and Hong Kong/Macao/Taiwan are excluded. This package contains **no HTML UI, MCP App, browser login or local form server**. Hosts own material collection, private review and customer confirmation. + +Sending currently uses customer-approved signatures and templates. `SPT_` public templates are not supported and must not be passed through the customer-template validation workflow. + +Use `get_template(template_id, signature, sub_account)` to retrieve a selected template's content and variables before preparing a send. `contentRedacted=true` means the text has been redacted or truncated and is not the complete original. Reading details does not send SMS. + +## Qualification workflow + +1. Call `get_qualification_requirements` for account rules and `privateInputSchemas`, then `create_qualification_draft(name, purpose)` (`1` own use, `2` third-party use). No application is submitted and no webpage URL is needed. +2. Stage files through the private exchange below. `upload_qualification_document` uploads a `qualification_image` file to the existing SMS material service and attaches it to a draft. +3. `recognize_qualification_document` returns a private `reviewFileId`. The host retrieves it outside model context for customer review. OCR never automatically confirms input. The host stages reviewed fields as a `qualification_data` JSON file and calls `update_qualification_information`. The account-specific JSON schemas describe those fields. +4. Check saved information with `check_qualification_information`. Use `set_qualification_responsible` to explicitly choose whether to reuse the operator. Legal-person supplemental information is optional and can be removed with `clear_qualification_legal`. +5. Where mobile verification is required, `prepare_qualification_verification_code` previews the recipient. After customer confirmation, `execute_sms_operation` sends the code. The host privately stages a `verification_code` file and calls `verify_qualification_code`. The server's staged copy is deleted after the verification attempt; the host manages the source file. +6. `prepare_qualification_application` returns a frozen preview and `operationId`. After customer confirmation, `execute_sms_operation` submits it. Information changes invalidate the old preview. Use `get_qualification_draft` for the submission result and `list_qualifications` for review status. + +Cancellation clears an unsubmitted draft; it does not withdraw an existing or uncertain application. Qualification tickets, uploaded-image addresses and upload credentials never appear in model tool results. + +## Private file exchange + +MCP tool arguments carry file references, not document Base64, personal fields or verification-code text. A host must provide a controlled path outside model context; ordinary chat attachments are not automatically such a path. + +| Kind | Content and limit | +| --- | --- | +| `qualification_image` | JPEG/PNG, 1 byte to 2 MB | +| `qualification_data` | UTF-8 JSON object, up to 64 KB; schemas from the requirements tool | +| `verification_code` | JSON object with a four-digit string `code`, up to 1 KB | +| `batch_csv` | UTF-8 CSV, up to 50 MB | + +**stdio:** explicitly configure an absolute `SMS_MCP_INPUT_ROOT`. The host places customer-provided files there and calls `import_input_file(relative_path, kind, content_type)`. Absolute input paths, traversal, symlinks and non-regular files are rejected. `export_qualification_review` writes private review JSON to a new file under the same root without overwriting files. The host must not read its contents into model context. + +**HTTP:** the host sends file bytes to `POST /files?kind=...` with the MCP bearer token and appropriate `Content-Type`, receiving an owned `fileId`. It privately retrieves review data through authenticated `GET /files/{fileId}`. These are host integration endpoints, not a standard MCP upload protocol. Remote tool calls cannot read the server's local import directory. Host support must be tested separately for each client. + +Ordinary batches use the same exchange: stage `batch_csv`, call `check_batch_file`, prepare and confirm creation, then separately prepare and confirm launch. API acceptance, task completion and delivered messages are separate states. + +## Configuration + +Common required variables: + +- `SMS_MCP_DATABASE_URL`: SQLAlchemy URL. SQLite is suitable for a single local process; replicas use the same PostgreSQL database. +- `SMS_MCP_ENCRYPTION_KEY`: shared Fernet key for encrypted drafts, files and execution records. +- `SMS_MCP_FLOW_TTL`: file/draft/preview lifetime, default 1800 seconds, at least 60. +- `SMS_MCP_OPERATION_TTL`: mutation record retention, default 86400 seconds, no shorter than draft lifetime. + +stdio additionally requires `SMS_MCP_LOCAL_SUBJECT`, `VOLCENGINE_ACCESS_KEY`, `VOLCENGINE_SECRET_KEY`, and optionally `VOLCENGINE_SESSION_TOKEN`. Configure `SMS_MCP_INPUT_ROOT` only when importing/exporting files. + +After the package is published: + +```bash +uvx --from 'mcp-server-sms==0.1.0' mcp-server-sms --transport stdio +``` + +Development installations can use source or an explicit wheel before merging or publishing. Local paths cannot be copied into a cloud client's configuration. + +HTTP additionally requires `SMS_MCP_PUBLIC_URL` (HTTPS origin), `SMS_MCP_OIDC_ISSUER` (existing token issuer with discovery/JWKS), and `SMS_MCP_TOKEN_AUDIENCE`. + +```bash +mcp-server-sms --transport streamable-http --host 0.0.0.0 --port 8000 +``` + +Connect to `/mcp`. Requests authenticate independently without initialize or Mcp-Session-Id. The deployment platform provides HTTPS, its existing authorization service and database; this package creates none of those resources. + +- `Authorization: Bearer ` validates signature, issuer, audience, expiry and subject using RS256 or ES256. +- `X-Volcengine-Credentials` contains Base64 JSON `{AccessKeyId, SecretAccessKey, SessionToken?}`, injected by a trusted client/gateway for that SMS request. Never log it or expose it to the model. Base64 is encoding only; use HTTPS. File endpoints require the MCP token only. + +Platform-specific authorization still needs integration verification. HTTP never falls back to process SMS credentials. Browser client IDs/secrets, session-signing keys and login callbacks are not required. + +See [.env.example](.env.example); inject actual secrets outside source control. `/health` is a liveness endpoint and `/.well-known/oauth-protected-resource/mcp` publishes resource metadata. + +## State and confirmation + +Stateless MCP transport does not remove business state. Explicit `draftId`, `fileId` and `operationId` records are owned, encrypted and expire. Completing/cancelling a draft clears its materials. HTTP periodically cleans expired records; stdio cleans them on subsequent calls. Hosts manage source files. + +An operation ID binds a preview, not customer consent. Hosts must display the current preview and enforce confirmation. Credential changes require a new preview; qualification drafts are bound to the credentials used when created. + +Unknown writes are never automatically replayed. Query `get_sms_operation_result` or `get_qualification_draft`; absent conclusive evidence, preserve `outcome_unknown`. Local deduplication is not an upstream idempotency guarantee. + +Started operations remain queryable for `SMS_MCP_OPERATION_TTL`, independently of the shorter preview lifetime. Expired previews still cannot execute again. + +## Development and acceptance + +```bash +uv sync --extra test +uv run pytest +uv run ruff check src tests --select F,E9 +uv build +uv run python -m twine check dist/* +``` + +Tests mock SMS responses and exercise the headless qualification workflow, stale previews, duplicate writes, crash recovery, code verification, file isolation, ordinary batches and transport. CI separately checks PostgreSQL sharing. + +`tests/live_readonly.py` and `tests/desktop_launcher.py` optionally reuse the designated master Skill's supported credential resolver and run an exact wheel. Credentials remain in process memory. Desktop launcher state is temporary and unsuitable for persistent deployment. Successful queries do not constitute acceptance of document uploads, verification messages, submissions or sends; those require separate customer authorization. + +Local TraeWork acceptance on 2026-09-14 covered resource queries, qualification draft creation/cancellation, invalid-material rejection, and one customer-confirmed SMS submission. The send API returned success and a Message ID; delivery was not yet confirmed. Real document/OCR, mobile verification, qualification/signature/template submissions, ordinary batches and cloud-host integration remain pending. + +The repository workflow builds packages for PRs and publishes new/version-changed packages after merging to main, using maintainer PyPI permissions. It does not deploy cloud services. + +## License + +Apache-2.0, retaining the official master Skill's copyright and business rules. Frontend assets, special notification batches, local login flows and the original local MCP App implementation are excluded. diff --git a/server/mcp_server_sms/README_zh.md b/server/mcp_server_sms/README_zh.md new file mode 100644 index 00000000..b1d3eae6 --- /dev/null +++ b/server/mcp_server_sms/README_zh.md @@ -0,0 +1,134 @@ +# 火山引擎短信 MCP Server + +[English](README.md) + +通过独立 MCP 工具完成国内短信的资质、签名、模板申请、发送及结果查询。沿用现有短信 OpenAPI,不改动 SMS 后端。业务规则来自官方 Skill `master@c7c49836dff727ca7a2ab297dc9d1ad62c5aec15`(1.4.4)。 + +支持 MCP **2026-07-28**、stdio 和无状态 Streamable HTTP,使用 MCP Python SDK 2.x。包版本为 **0.1.0**;包、MCP 协议和短信 API 的版本分别管理。 + +## 工具范围 + +| 场景 | 工具 | +| --- | --- | +| 开通检查与消息组 | `list_message_groups`、`get_message_group` | +| 资质规则与草稿 | `get_qualification_requirements`、`create_qualification_draft`、`update_qualification_base`、`get_qualification_draft`、`cancel_qualification_draft` | +| 材料与信息 | `import_input_file`、`upload_qualification_document`、`recognize_qualification_document`、`export_qualification_review`、`update_qualification_information`、`set_qualification_responsible`、`clear_qualification_legal` | +| 资质校验与申请 | `check_qualification_information`、`prepare_qualification_verification_code`、`verify_qualification_code`、`prepare_qualification_application`、`list_qualifications` | +| 签名与模板 | `list_signatures`、`list_templates`、`get_template`、`match_template`、`prepare_signature`、`prepare_template` | +| 发送与回执 | `prepare_send`、`get_send_status`、`analyze_delivery` | +| 普通群发 | `get_batch_csv_template`、`check_batch_file`、`prepare_batch_task`、`get_batch_task`、`list_batch_tasks`、`prepare_batch_launch`、`prepare_batch_cancel` | +| 执行与核对 | `execute_sms_operation`、`get_sms_operation_result` | + +普通群发支持国内通知和营销短信、CSV 文件、立即或定时发送。不支持验证码群发、特殊通知群发、国际及港澳台短信。 + +当前发送流程使用客户已审核的签名和模板,暂不支持 `SPT_` 公共模板。不能把公共模板套用到客户模板的校验流程。 + +Server 不提供 HTML 页面、MCP App、浏览器登录或本地表单。材料收集、私密展示和客户确认由宿主或已有业务页面负责;工具不会自动生成这些界面。 + +发送前可用 `get_template(template_id, signature, sub_account)` 获取选定模板的正文和变量。`contentRedacted=true` 表示正文经过脱敏或截断,不应当作完整原文展示。模板详情查询不发送短信,发送前仍须生成当前预览。 + +## 资质申请流程 + +1. 查询消息组。只有 `RE:0001` 表示未开通;客户在[短信控制台](https://console.volcengine.com/sms)自行阅读协议并开通。 +2. 调用 `get_qualification_requirements` 获取账号材料规则及 `privateInputSchemas`。调用 `create_qualification_draft(name, purpose)` 创建短期草稿;不创建线上申请、不要求 HTTPS 网页地址。 +3. 宿主通过下述文件渠道提供图片。`upload_qualification_document` 将图片上传到既有短信材料服务并关联草稿。营业证件指定证件类型,居民身份证指定类型 `0` 和 `front`/`back`;其他法人证件和补充材料使用从 `0` 开始的连续 `index`,`replace=true` 表示从本次文件开始替换该组旧附件。 +4. `recognize_qualification_document` 识别已上传的营业证件或身份证正反面,返回 `reviewFileId`。宿主取得该文件供客户私密核对;OCR 不自动保存为已确认信息。客户确认的字段由宿主按 `privateInputSchemas` 生成 `qualification_data` 文件,再调用 `update_qualification_information` 保存。材料名称、用途由草稿基本信息单独管理,不被 OCR 覆盖。 +5. 用 `check_qualification_information` 校验企业、经办人及必要的法人/责任人信息。`set_qualification_responsible` 明确是否与经办人相同;相同时复用。法人补充材料可选;`clear_qualification_legal` 可移除已填写的法人补充材料,营业证件上的法人姓名仍保留。 +6. 账号要求手机号验证时,先 `prepare_qualification_verification_code` 展示脱敏号码;客户确认后通过 `execute_sms_operation` 发码。宿主私密接收验证码并提供 `verification_code` 文件,由 `verify_qualification_code` 校验。验证码服务端暂存副本在调用后清除;源文件由宿主管理。 +7. 材料与校验齐备后调用 `prepare_qualification_application`,向客户展示当前预览。客户确认后使用 `execute_sms_operation(operation_id)` 提交;申请、发送和验证码发送均不能自动确认。 +8. `get_qualification_draft` 获取提交结果,成功后通过 `list_qualifications` 查询审核状态及原因。取消仅清除未提交的草稿,不撤销线上申请或结果未知的请求。 + +信息或材料变化会使已有预览失效。校验票据、上传凭证和图片地址不返回模型。企业未通过自动校验时按既有业务规则进入人工审核;经办人及独立责任人的必要校验必须通过。 + +## 文件输入与私密核对 + +工具参数只接收文件引用,不接收图片 Base64、验证码原文或原始个人字段。宿主须实现不进入模型上下文的材料渠道,不能假设普通聊天附件会自动、安全地传给 Server。 + +| 文件 kind | 内容与限制 | +| --- | --- | +| `qualification_image` | JPEG 或 PNG,每张 1 字节至 2 MB | +| `qualification_data` | UTF-8 JSON 对象,最多 64 KB;字段规范由资质要求工具返回 | +| `verification_code` | `{"code":"1234"}` 格式的 JSON 对象,四位数字,最多 1 KB | +| `batch_csv` | UTF-8 CSV,最多 50 MB;号码及变量随后按所选模板校验 | + +**stdio:** 管理员显式配置 `SMS_MCP_INPUT_ROOT`。宿主将客户提供的文件放入该目录,调用 `import_input_file(relative_path, kind, content_type)` 得到 `fileId`。只允许目录内普通文件,拒绝绝对路径、目录穿越和符号链接。`export_qualification_review` 可将私密 OCR JSON 写入该目录内一个尚不存在的文件,供客户核对。宿主不能把核对文件读入模型对话。 + +**HTTP:** 宿主使用与 MCP 相同的访问令牌,在模型之外调用 `POST /files?kind=...`,请求体为原始文件字节,`Content-Type` 与内容一致。返回 `fileId`、类型、大小、指纹及有效期。宿主通过 `GET /files/{fileId}` 私密读取 OCR 核对数据;文件只允许原用户访问。HTTP 模式禁止通过 MCP 工具读取服务器的本地材料目录。 + +这两个 HTTP 文件接口属于宿主集成接口,不是 MCP 文件上传标准。是否支持这条私密文件通道,须按 TraeWork、豆包工作等实际宿主分别验收。 + +普通群发使用同一文件渠道:导入 `batch_csv` → `check_batch_file` → `prepare_batch_task` → 客户确认后执行创建 → `prepare_batch_launch` → 再次确认并执行启动。任务创建、任务处理完成与每个号码送达分别判断。 + +## 运行配置 + +必填公共配置: + +| 变量 | 用途 | +| --- | --- | +| `SMS_MCP_DATABASE_URL` | SQLAlchemy URL;单机可用 SQLite,多副本使用同一个 PostgreSQL | +| `SMS_MCP_ENCRYPTION_KEY` | Fernet 密钥,加密草稿、文件和执行结果;副本之间共享 | +| `SMS_MCP_FLOW_TTL` | 文件、草稿和预览有效期,默认 1800 秒,至少 60 秒 | +| `SMS_MCP_OPERATION_TTL` | 执行去重记录有效期,默认 86400 秒,不得短于草稿有效期 | + +stdio 配置 `SMS_MCP_LOCAL_SUBJECT` 作为本地记录所有者,并通过 `VOLCENGINE_ACCESS_KEY`、`VOLCENGINE_SECRET_KEY`、可选 `VOLCENGINE_SESSION_TOKEN` 指定短信凭据。`SMS_MCP_INPUT_ROOT` 仅在需要导入或导出文件时配置,必须为绝对目录路径。 + +包正式发布后可运行: + +```bash +uvx --from 'mcp-server-sms==0.1.0' mcp-server-sms --transport stdio +``` + +开发版可用当前源码或明确的 wheel 路径安装,无需等待合码或 PyPI 发布。不要将本机文件路径直接复制到云端配置。 + +HTTP 另外需要: + +| 变量 | 用途 | +| --- | --- | +| `SMS_MCP_PUBLIC_URL` | 对外 HTTPS origin;服务挂载在域名根路径 | +| `SMS_MCP_OIDC_ISSUER` | 部署平台提供的现有令牌签发方,支持标准 discovery/JWKS | +| `SMS_MCP_TOKEN_AUDIENCE` | MCP 访问令牌的 audience | + +```bash +mcp-server-sms --transport streamable-http --host 0.0.0.0 --port 8000 +``` + +客户端连接 `/mcp`,每次请求独立鉴权,不依赖 initialize 或 Mcp-Session-Id。部署方负责 HTTPS、现有授权服务和数据库,本包不创建云资源或身份应用。 + +- `Authorization: Bearer `:验证签名、issuer、audience、有效期和 `sub`,只允许 RS256 或 ES256。 +- `X-Volcengine-Credentials`:由可信客户端或网关注入 Base64 JSON `{AccessKeyId, SecretAccessKey, SessionToken?}`,用于本次短信调用,不得记录或向模型暴露。文件接口只需 MCP token,不需要短信凭据。Base64 仅编码,网络传输必须使用 HTTPS。 + +这仍需对接具体托管平台的授权约定;未验证的平台不能声称开箱即用。HTTP 不回退到进程默认短信账号。不再需要网页登录 client ID、client secret、会话签名密钥或浏览器回调地址。 + +参考 [.env.example](.env.example) 提供配置。实际密钥用部署平台的 secret 注入,不提交到仓库。`GET /health` 用于存活检查,`/.well-known/oauth-protected-resource/mcp` 提供资源元数据。 + +## 执行、状态与重试 + +协议无状态不等于业务无状态。文件和草稿通过明确的 `fileId`、`draftId` 关联;每次访问都检查归属与有效期。草稿仅保存当前流程需要的信息,完成或取消后清除材料;文件到期不可访问,HTTP 定期清理,stdio 在后续调用时清理。导入源文件由宿主管理。 + +`operationId` 绑定预览内容和调用凭据,不是客户同意的证明;宿主负责展示和确认。凭据变化需要重新预览;资质草稿绑定创建时的凭据,授权有效期应覆盖该流程。 + +写请求结果未知时不自动重发、不新建相同操作绕过保护。先用 `get_sms_operation_result` 或 `get_qualification_draft` 查询记录;没有确切证据时返回 `outcome_unknown`。本地去重保护不等于上游 API 支持幂等键。 + +已开始执行的操作可以在 `SMS_MCP_OPERATION_TTL` 保留期内查询结果,不受较短的预览有效期影响;过期预览仍不能再次执行。 + +## 开发与验收 + +```bash +uv sync --extra test +uv run pytest +uv run ruff check src tests --select F,E9 +uv build +uv run python -m twine check dist/* +``` + +自动测试使用模拟短信响应,覆盖无网页依赖的资质工具流程、预览失效、重复提交、未知结果恢复、手机号验证、文件隔离、普通群发和协议兼容。CI 另运行 PostgreSQL 共享存储测试。 + +`tests/live_readonly.py` 和 `tests/desktop_launcher.py` 用于可选的真实账号验收。它们使用指定 master Skill 的既有登录解析器,并从指定 wheel 运行 Server;凭据只在进程内传递。桌面启动脚本使用临时状态目录,结束后删除,不能用作持久部署。真实材料上传、验证码发送、申请提交、短信发送分别需要客户授权;查询成功不代表这些写操作已通过验收。 + +2026-09-14 本地 TraeWork 验收已覆盖资源查询、资质草稿创建与取消、非法材料拒绝,以及经客户确认的单条短信提交;发送接口返回成功和 Message ID,送达回执尚未确认。真实材料/OCR、手机号验证、资质/签名/模板申请提交、普通群发和云端宿主集成仍待验收。 + +仓库现有工作流在 PR 中构建检查新增包;合入 main 后对新增包或版本变更发布 PyPI,发布权限由仓库维护者提供。该工作流不负责云端部署。 + +## 来源与许可证 + +保留官方 master Skill 的 Apache-2.0 许可证。适配内容为短信 Action、签名、字段过滤、普通发送/群发、分析、资质校验与提交规则;不包含特殊通知群发、前端资产、CLI 登录流程或本机 MCP App 实现。 diff --git a/server/mcp_server_sms/pyproject.toml b/server/mcp_server_sms/pyproject.toml new file mode 100644 index 00000000..aff1a986 --- /dev/null +++ b/server/mcp_server_sms/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "mcp-server-sms" +version = "0.1.0" +description = "Volcengine domestic SMS qualification, application and delivery tools for MCP" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "Apache-2.0"} +dependencies = [ + "mcp[cli]>=2.2,<3", + "pydantic>=2.11,<3", + "starlette>=0.47,<1", + "uvicorn>=0.35,<1", + "httpx>=0.28,<1", + "sqlalchemy>=2.0,<3", + "psycopg[binary]>=3.2,<4", + "cryptography>=45", + "PyJWT[crypto]>=2.10,<3", +] + +[project.scripts] +mcp-server-sms = "mcp_server_sms.server:main" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-asyncio>=1", "build>=1", "twine>=6", "ruff>=0.16,<1"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_server_sms"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/server/mcp_server_sms/src/mcp_server_sms/__init__.py b/server/mcp_server_sms/src/mcp_server_sms/__init__.py new file mode 100644 index 00000000..de36bfd9 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/__init__.py @@ -0,0 +1,5 @@ +"""Volcengine SMS MCP server.""" + +from importlib.metadata import version + +__version__ = version("mcp-server-sms") diff --git a/server/mcp_server_sms/src/mcp_server_sms/auth.py b/server/mcp_server_sms/src/mcp_server_sms/auth.py new file mode 100644 index 00000000..771f67bb --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/auth.py @@ -0,0 +1,90 @@ +"""Separate MCP caller identity from request-scoped SMS credentials.""" + +import base64 +import json +import os +import threading + +import httpx +import jwt + +from .core.api_protocol import ResolvedCredentials + + +class AuthenticationError(ValueError): + pass + + +def subject_identity(issuer: str, subject: str) -> str: + return json.dumps([issuer, subject], separators=(",", ":")) + + +class Authenticator: + def __init__(self, settings): + self.settings = settings + self._keys = None + self._lock = threading.Lock() + + def owner(self, headers): + value = headers.get("authorization", "") + scheme, _, token = value.partition(" ") + if scheme.lower() != "bearer" or not token: + raise AuthenticationError("需要有效的 MCP 访问令牌") + try: + with self._lock: + if self._keys is None: + metadata_url = ( + self.settings.oidc_issuer.rstrip("/") + + "/.well-known/openid-configuration" + ) + response = httpx.get(metadata_url, timeout=10) + response.raise_for_status() + metadata = response.json() + if metadata["issuer"] != self.settings.oidc_issuer or not metadata[ + "jwks_uri" + ].startswith("https://"): + raise ValueError("Invalid issuer metadata") + self._keys = jwt.PyJWKClient(metadata["jwks_uri"], timeout=10) + key = self._keys.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + key.key, + algorithms=["RS256", "ES256"], + issuer=self.settings.oidc_issuer, + audience=self.settings.token_audience, + options={"require": ["exp", "iss", "aud", "sub"]}, + ) + if not isinstance(claims["sub"], str) or not claims["sub"]: + raise ValueError("Missing subject") + return subject_identity(claims["iss"], claims["sub"]) + except Exception as exc: + raise AuthenticationError("MCP 访问令牌无效或暂时无法验证") from exc + + +def sms_credentials(headers=None): + if headers is None: + values = { + "AccessKeyId": os.getenv("VOLCENGINE_ACCESS_KEY", ""), + "SecretAccessKey": os.getenv("VOLCENGINE_SECRET_KEY", ""), + "SessionToken": os.getenv("VOLCENGINE_SESSION_TOKEN", ""), + } + else: + encoded = headers.get("x-volcengine-credentials", "") + try: + if len(encoded) > 16384: + raise ValueError("oversized header") + values = json.loads(base64.b64decode(encoded, validate=True)) + if not isinstance(values, dict): + raise ValueError("invalid credential object") + except Exception as exc: + raise AuthenticationError("缺少或无法解析短信调用凭据") from exc + if any( + not isinstance(values.get(key), str) or not values[key] + for key in ("AccessKeyId", "SecretAccessKey") + ): + raise AuthenticationError("短信调用凭据不完整") + if not isinstance(values.get("SessionToken", ""), str): + raise AuthenticationError("短信临时凭据格式无效") + return ResolvedCredentials( + values["AccessKeyId"], values["SecretAccessKey"], values.get("SessionToken", "") + ) diff --git a/server/mcp_server_sms/src/mcp_server_sms/config.py b/server/mcp_server_sms/src/mcp_server_sms/config.py new file mode 100644 index 00000000..9fece855 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/config.py @@ -0,0 +1,67 @@ +"""Explicit configuration for the independently deployed MCP service.""" + +import os +from dataclasses import dataclass, field +from urllib.parse import urlsplit + + +@dataclass(frozen=True) +class Settings: + database_url: str = field(repr=False) + encryption_key: str = field(repr=False) + public_url: str = "" + oidc_issuer: str = "" + token_audience: str = "" + input_root: str = "" + flow_ttl: int = 1800 + operation_ttl: int = 86400 + + def __post_init__(self): + if not 60 <= self.flow_ttl <= self.operation_ttl: + raise ValueError("TTL must satisfy 60 <= flow TTL <= operation TTL") + if self.input_root and not os.path.isabs(self.input_root): + raise ValueError("SMS_MCP_INPUT_ROOT must be an absolute directory path") + + @classmethod + def from_env(cls): + required = ("SMS_MCP_DATABASE_URL", "SMS_MCP_ENCRYPTION_KEY") + missing = [key for key in required if not os.environ.get(key)] + if missing: + raise ValueError("Missing configuration: " + ", ".join(missing)) + return cls( + database_url=os.environ[required[0]], + encryption_key=os.environ[required[1]], + public_url=os.getenv("SMS_MCP_PUBLIC_URL", ""), + oidc_issuer=os.getenv("SMS_MCP_OIDC_ISSUER", ""), + token_audience=os.getenv("SMS_MCP_TOKEN_AUDIENCE", ""), + input_root=os.getenv("SMS_MCP_INPUT_ROOT", ""), + flow_ttl=int(os.getenv("SMS_MCP_FLOW_TTL", "1800")), + operation_ttl=int(os.getenv("SMS_MCP_OPERATION_TTL", "86400")), + ) + + def validate_http(self): + for key in ( + "public_url", + "oidc_issuer", + "token_audience", + ): + if not getattr(self, key): + raise ValueError(f"HTTP mode requires {key}") + for value in (self.public_url, self.oidc_issuer): + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.query + or parsed.fragment + or parsed.username + ): + raise ValueError( + "Public URL and OIDC issuer must be HTTPS URLs without credentials/query/fragment" + ) + if urlsplit(self.public_url).path not in ("", "/"): + raise ValueError( + "SMS_MCP_PUBLIC_URL must be an origin; mount this service at its root" + ) + if not 60 <= self.flow_ttl <= self.operation_ttl: + raise ValueError("TTL must satisfy 60 <= flow TTL <= operation TTL") diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/__init__.py b/server/mcp_server_sms/src/mcp_server_sms/core/__init__.py new file mode 100644 index 00000000..a1ba7bba --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/__init__.py @@ -0,0 +1 @@ +"""SMS domain contracts and workflows adapted from the official Skill.""" diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/action_contracts.py b/server/mcp_server_sms/src/mcp_server_sms/core/action_contracts.py new file mode 100644 index 00000000..2e807bab --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/action_contracts.py @@ -0,0 +1,480 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +"""Machine-readable contracts for every public SMS Action used by the Skill.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, Optional, Set + + +SMS_API_VERSION = "2026-01-01" +LIVE_VALIDATION_ACTIONS = frozenset( + { + "ListSubAccountForAgent", + "GetSubAccountDetail", + "GetSignatureIdentificationList", + "ListAllSmsProduct", + "ListSignatureForAgent", + "ListSmsTemplateForAgent", + "ListSecondTemplate", + "ListSmsSendLogForAgent", + "ListTotalSendCountStatForAgent", + "GetBatchTaskDetail", + "GetBatchTaskList", + } +) + + +@dataclass(frozen=True) +class ActionSpec: + version: str + method: str + read_only: bool + reconciliation_action: Optional[str] + # None keeps the complete sanitized Result; a frozenset is an output allowlist. + result_fields: Optional[frozenset] + idempotency_field: Optional[str] = None + required_result_fields: frozenset = frozenset() + required_result_any: frozenset = frozenset() + cli_supported: bool = True + + +COMMON_PAGE_FIELDS = { + "List", + "list", + "Items", + "items", + "Total", + "total", + "Page", + "page", + "PageSize", + "pageSize", + "PageIndex", + "pageIndex", +} +_MESSAGE_GROUP_FIELDS = { + "SubAccount", + "SubAccountName", + "ChannelType", + "ChannelTypes", + "Status", + "CreatedAt", +} +MESSAGE_GROUP_DETAIL_FIELDS = { + "subAccountId", + "subAccountName", + "status", + "channelTypeToIndustryConfig", + "channelType", + "channelTypeCn", + "industry", + "industryCn", +} +_QUALIFICATION_FIELDS = { + "id", + "purpose", + "materialName", + "businessCertificateName", + "effectSignatures", + "auditStatus", + "auditOpinion", + "auditedAt", + "usable", + "isOrder", +} +_QUALIFICATION_REQUIREMENT_FIELDS = { + "isOnlyTwoElement", + "isSkipOtherUse", + "needOtherUseCheck", + "operatorThreeElement", + "needOperatorImage", + "needOperatorMobile", + "responsibleThreeElement", + "needResponsibleImage", + "needResponsibleMobile", + "needBusinessCertificateImage", +} +_QUALIFICATION_OCR_FIELDS = { + "businessCertificateType", + "businessCertificateName", + "unifiedSocialCreditIdentifier", + "businessCertificateValidityPeriodStart", + "businessCertificateValidityPeriodEnd", + "legalPersonName", + "personName", + "personIDCard", + "isIDCardValid", +} +_QUALIFICATION_CHECK_FIELDS = {"status", "ticket"} +_ACCOUNT_IDENTITY_FIELDS = {"businessName", "userType"} +_VERIFY_CODE_SEND_FIELDS = {"messageId"} +_VERIFY_CODE_CHECK_FIELDS = {"status", "sendType"} +_QUALIFICATION_UPLOAD_TOKEN_FIELDS = { + "token", + "accessKeyId", + "secretAccessKey", + "sessionToken", + "expiredTime", + "currentTime", +} +_SIGNATURE_FIELDS = { + "Signature", + "Description", + "Source", + "Domain", + "Scene", + "ProjectName", + "AppIcp", + "Trademark", + "Status", + "StatusDescription", + "SubAccounts", + "ChannelTypes", + "ChannelType", + "Purpose", + "IdentificationId", + "IdentificationID", + "usable", + "Usable", + "CreatedAt", + "UpdatedAt", +} +TEMPLATE_FIELDS = { + "TemplateId", + "templateId", + "SecondTemplateId", + "secondTemplateId", + "TemplateName", + "templateName", + "Name", + "name", + "Content", + "content", + "TemplateParams", + "templateParams", + "ParamName", + "ChannelType", + "channelType", + "Signature", + "signature", + "Signatures", + "signatures", + "SubAccounts", + "subAccounts", + "Status", + "status", + "StatusDescription", + "statusDescription", + "Description", + "description", + "Project", + "project", + "CreatedAt", + "createdAt", + "UpdatedAt", + "updatedAt", + "Area", + "area", + "ShortUrlConfig", + "shortUrlConfig", +} +TEMPLATE_PARAM_FIELDS = {"name", "Name", "ParamName"} +TEMPLATE_SCALAR_LIST_FIELDS = { + "Signatures", + "signatures", + "SubAccounts", + "subAccounts", +} +_SHORT_URL_CONFIG_FIELDS = { + "isEnabled", + "belong", + "isNeedClickDetails", + "uaCheckStrategy", +} +_SIGNATURE_APPLICATION_FIELDS = {"applyId", "status", "reason"} +_TEMPLATE_APPLICATION_FIELDS = { + "templateId", + "status", + "statusDescription", + "auditOpinion", +} +_SEND_RESULT_FIELDS = {"MessageId", "MessageIds"} +_SEND_LOG_FIELDS = { + "MessageId", + "ErrorCode", + "SendTime", + "ReceiptTime", + "TemplateId", + "Signature", + "SubAccount", + "Count", +} +_STAT_FIELDS = { + "TotalSendCount", + "TotalAllSendCount", + "TotalSendSuccessCount", + "TotalReceiptSuccessCount", + "TotalReceiptFailureCount", +} +_UPLOAD_FIELDS = {"file", "url"} +_TEMPLATE_DEMO_FIELDS = {"fileName", "value", "contentType", "size"} +TEMPLATE_DEMO_CSV_MEDIA_TYPES = frozenset( + { + "application/octet-stream", + "application/csv", + "application/vnd.ms-excel", + "text/comma-separated-values", + "text/csv", + } +) +_BATCH_TASK_FIELDS = { + "taskId", + "subAccount", + "taskName", + "signature", + "templateId", + "templateName", + "channelType", + "scheduled", + "sendTime", + "fileUrl", + "status", + "totalCount", +} +_BATCH_CREATE_FIELDS = {"taskId", "dupCount", "totalCount"} +_PAGE_RESULT_FIELDS = frozenset({"List", "list", "Items", "items"}) + + +def _fields(*groups: Iterable[str]) -> frozenset: + result: Set[str] = set(COMMON_PAGE_FIELDS) + for group in groups: + result.update(group) + return frozenset(result) + + +ACTION_REGISTRY: Dict[str, ActionSpec] = { + "ListSubAccountForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ListSubAccountForAgent", + _fields(_MESSAGE_GROUP_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "GetSubAccountDetail": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "GetSubAccountDetail", + frozenset(MESSAGE_GROUP_DETAIL_FIELDS), + required_result_fields=frozenset({"subAccountId"}), + ), + "GetSignatureIdentificationList": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "GetSignatureIdentificationList", + _fields(_QUALIFICATION_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "GetAccountIdentRankForAgent": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "GetAccountIdentRankForAgent", + frozenset(_QUALIFICATION_REQUIREMENT_FIELDS), + required_result_fields=frozenset(_QUALIFICATION_REQUIREMENT_FIELDS), + cli_supported=False, + ), + "ListAllSmsProduct": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "ListAllSmsProduct", + frozenset(_ACCOUNT_IDENTITY_FIELDS), + required_result_fields=frozenset(_ACCOUNT_IDENTITY_FIELDS), + cli_supported=False, + ), + "GetMUploadParam": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "GetMUploadParam", + frozenset(_QUALIFICATION_UPLOAD_TOKEN_FIELDS), + required_result_fields=frozenset({"token"}), + cli_supported=False, + ), + "GetOCRLicenseForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "GetOCRLicenseForAgent", + frozenset(_QUALIFICATION_OCR_FIELDS), + cli_supported=False, + ), + "ThreeElementEnterpriseCheckForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ThreeElementEnterpriseCheckForAgent", + frozenset(_QUALIFICATION_CHECK_FIELDS), + required_result_fields=frozenset({"status"}), + cli_supported=False, + ), + "ThreeElementPersonCheckForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ThreeElementPersonCheckForAgent", + frozenset(_QUALIFICATION_CHECK_FIELDS), + required_result_fields=frozenset({"status"}), + cli_supported=False, + ), + "ApplySignatureIdentificationForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "GetSignatureIdentificationList", + frozenset(), + cli_supported=False, + ), + "SendSmsVerifyCodeByMobile": ActionSpec( + SMS_API_VERSION, + "POST", + False, + None, + frozenset(_VERIFY_CODE_SEND_FIELDS), + required_result_fields=frozenset({"messageId"}), + cli_supported=False, + ), + "CheckSmsVerifyCodeByMobile": ActionSpec( + SMS_API_VERSION, + "POST", + False, + None, + frozenset(_VERIFY_CODE_CHECK_FIELDS), + required_result_fields=frozenset({"status"}), + cli_supported=False, + ), + "ListSignatureForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ListSignatureForAgent", + _fields(_SIGNATURE_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "ListSmsTemplateForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ListSmsTemplateForAgent", + _fields(TEMPLATE_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "ListSecondTemplate": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "ListSecondTemplate", + _fields(TEMPLATE_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "ApplySmsSignatureV2": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "ListSignatureForAgent", + frozenset(_SIGNATURE_APPLICATION_FIELDS), + required_result_any=frozenset({"applyId", "status"}), + ), + "ApplySmsTemplateV2": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "ListSmsTemplateForAgent", + frozenset(_TEMPLATE_APPLICATION_FIELDS), + required_result_any=frozenset({"templateId", "status"}), + ), + "SendSmsForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "ListSmsSendLogForAgent", + frozenset(_SEND_RESULT_FIELDS), + required_result_any=frozenset({"MessageId", "MessageIds"}), + ), + "ListSmsSendLogForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ListSmsSendLogForAgent", + _fields(_SEND_LOG_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "ListTotalSendCountStatForAgent": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "ListTotalSendCountStatForAgent", + _fields(_STAT_FIELDS), + required_result_any=frozenset(_STAT_FIELDS), + ), + "GetUploadTosURL": ActionSpec( + SMS_API_VERSION, + "GET", + False, + None, + frozenset(_UPLOAD_FIELDS), + required_result_fields=frozenset(_UPLOAD_FIELDS), + ), + "TemplateUploadDemo": ActionSpec( + SMS_API_VERSION, + "POST", + True, + "TemplateUploadDemo", + frozenset(_TEMPLATE_DEMO_FIELDS), + ), + "SetBatchTask": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "GetBatchTaskDetail", + frozenset(_BATCH_CREATE_FIELDS), + required_result_fields=frozenset({"taskId"}), + ), + "GetBatchTaskDetail": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "GetBatchTaskDetail", + frozenset(_BATCH_TASK_FIELDS), + required_result_fields=frozenset({"taskId"}), + ), + "GetBatchTaskList": ActionSpec( + SMS_API_VERSION, + "GET", + True, + "GetBatchTaskList", + _fields(_BATCH_TASK_FIELDS), + required_result_any=_PAGE_RESULT_FIELDS, + ), + "ConsentBatchTask": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "GetBatchTaskDetail", + frozenset(), + ), + "DeleteBatchTask": ActionSpec( + SMS_API_VERSION, + "POST", + False, + "GetBatchTaskDetail", + frozenset(), + ), +} diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/analytics.py b/server/mcp_server_sms/src/mcp_server_sms/core/analytics.py new file mode 100644 index 00000000..4758ccf1 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/analytics.py @@ -0,0 +1,548 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Customer-visible analytics for the external domestic SMS Actions. + +This module intentionally uses only ``ListTotalSendCountStatForAgent`` and +``ListSmsSendLogForAgent``. It exposes only customer-visible fields and does +not infer or return non-public implementation data. +""" + +from __future__ import annotations + +import datetime +import re +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Sequence + +SHANGHAI_TZ = datetime.timezone(datetime.timedelta(hours=8)) +TIMEZONE_NAME = "Asia/Shanghai" +MAX_WINDOW = datetime.timedelta(days=90) +MAX_BUCKETS = 100 +MAX_LOGICAL_QUERIES = 120 +ALLOWED_DIMENSIONS = frozenset({"subAccount", "signature", "template", "time"}) +SUCCESS_ERROR_CODES = frozenset({"", "0", "OK", "SUCCESS", "DELIVRD"}) +CHANNEL_TYPES = frozenset({"CN_OTP", "CN_NTC", "CN_MKT"}) +MOBILE_PATTERN = re.compile(r"^1[3-9]\d{9}$") + + +class AnalyticsError(ValueError): + def __init__(self, message: str, code: str = "analytics_validation_error") -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class TimeWindow: + start: datetime.datetime + end: datetime.datetime + timezone: str = TIMEZONE_NAME + + @property + def start_epoch(self) -> int: + return int(self.start.timestamp()) + + @property + def end_epoch(self) -> int: + return int(self.end.timestamp()) + + +def _parse_time(value: str, name: str) -> datetime.datetime: + try: + parsed = datetime.datetime.fromisoformat(value) + except (TypeError, ValueError) as exc: + raise AnalyticsError("{} must be an ISO 8601 timestamp".format(name)) from exc + if parsed.microsecond: + raise AnalyticsError("{} must have whole-second precision".format(name)) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=SHANGHAI_TZ) + return parsed.astimezone(SHANGHAI_TZ) + + +def parse_window(start: str, end: str) -> TimeWindow: + """Parse an explicit ``[start, end)`` window in Asia/Shanghai.""" + start_value = _parse_time(start, "start") + end_value = _parse_time(end, "end") + duration = end_value - start_value + if duration <= datetime.timedelta(0): + raise AnalyticsError("end must be later than start") + if duration > MAX_WINDOW: + raise AnalyticsError("analytics window must not exceed 90 days") + return TimeWindow(start_value, end_value) + + +def time_buckets(window: TimeWindow, granularity: str) -> List[TimeWindow]: + """Return stable local hour/day buckets, clipped to the requested window.""" + if granularity == "total": + return [window] + if granularity not in {"hour", "day"}: + raise AnalyticsError("bucket must be one of: total, hour, day") + + if granularity == "hour": + boundary = window.start.replace(minute=0, second=0) + datetime.timedelta( + hours=1 + ) + step = datetime.timedelta(hours=1) + else: + boundary = window.start.replace( + hour=0, minute=0, second=0 + ) + datetime.timedelta(days=1) + step = datetime.timedelta(days=1) + + buckets: List[TimeWindow] = [] + cursor = window.start + while cursor < window.end: + bucket_end = min(boundary, window.end) + buckets.append(TimeWindow(cursor, bucket_end)) + if len(buckets) > MAX_BUCKETS: + raise AnalyticsError( + "requested bucket size would exceed 100 aggregate queries" + ) + cursor = bucket_end + boundary += step + return buckets + + +def validate_dimension(dimension: str) -> str: + if dimension not in ALLOWED_DIMENSIONS: + raise AnalyticsError( + "dimension must be one of subAccount, signature, template, or time" + ) + return dimension + + +def _nonnegative_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, int) and value >= 0: + return value + if isinstance(value, float) and value >= 0 and value.is_integer(): + return int(value) + return None + + +def _rate( + numerator: Optional[int], + denominator: Optional[int], + numerator_field: str, + denominator_field: str, +) -> Dict[str, Any]: + result: Dict[str, Any] = { + "status": "insufficient_data", + "numerator": numerator, + "denominator": denominator, + "percentage": None, + "numerator_field": numerator_field, + "denominator_field": denominator_field, + } + if numerator is None or denominator is None or denominator == 0: + return result + if numerator > denominator: + return result + result["status"] = "available" + result["percentage"] = round(numerator * 100.0 / denominator, 4) + return result + + +def normalize_stats(raw: Mapping[str, Any]) -> Dict[str, Any]: + """Normalize public aggregate fields without trusting service rate fields.""" + all_send = _nonnegative_int(raw.get("TotalAllSendCount")) + send_success = _nonnegative_int(raw.get("TotalSendSuccessCount")) + receipt_success = _nonnegative_int(raw.get("TotalReceiptSuccessCount")) + receipt_failure = _nonnegative_int(raw.get("TotalReceiptFailureCount")) + service_send_count = _nonnegative_int(raw.get("TotalSendCount")) + + receipts: Dict[str, Any] = { + "status": "insufficient_data", + "success": receipt_success, + "failure": receipt_failure, + "outstanding": None, + "denominator": send_success, + "denominator_field": "TotalSendSuccessCount", + } + if ( + send_success is not None + and receipt_success is not None + and receipt_failure is not None + and receipt_success + receipt_failure <= send_success + ): + receipts["status"] = "available" + receipts["outstanding"] = send_success - receipt_success - receipt_failure + + return { + "service_reported_send_count": { + "value": service_send_count, + "source_field": "TotalSendCount", + "semantic": "not_used_as_recipient_or_rate_denominator", + }, + "submission_success_rate": _rate( + send_success, + all_send, + "TotalSendSuccessCount", + "TotalAllSendCount", + ), + "receipt_success_rate": _rate( + receipt_success, + send_success, + "TotalReceiptSuccessCount", + "TotalSendSuccessCount", + ), + "receipts": receipts, + } + + +def _require_result(envelope: Mapping[str, Any], action: str) -> Mapping[str, Any]: + if not envelope.get("success"): + error = envelope.get("error") + message = error.get("message") if isinstance(error, Mapping) else None + raise AnalyticsError( + str(message or "{} failed".format(action)), + "analytics_query_failed", + ) + result = envelope.get("result") + if not isinstance(result, Mapping): + raise AnalyticsError( + "{} returned no public result".format(action), + "analytics_query_failed", + ) + return result + + +def _window_json(window: TimeWindow) -> Dict[str, Any]: + return { + "start_inclusive": window.start.isoformat(), + "end_exclusive": window.end.isoformat(), + } + + +def build_report( + client: Any, + *, + start: str, + end: str, + sub_account: Optional[str] = None, + channel_type: Optional[str] = None, + signature: Optional[str] = None, + template_id: Optional[str] = None, + mobile: Optional[str] = None, + bucket: str = "day", + include_logs: bool = False, + page_size: int = 100, + max_pages: int = 10, + dimension: str = "time", +) -> Dict[str, Any]: + """Build aggregate trends and, when requested, safe send-log analysis.""" + window = parse_window(start, end) + validate_dimension(dimension) + if include_logs and not sub_account: + raise AnalyticsError("send-log analysis requires sub-account") + if channel_type and channel_type not in CHANNEL_TYPES: + raise AnalyticsError("channel-type must be one of CN_OTP, CN_NTC, or CN_MKT") + if channel_type and include_logs: + raise AnalyticsError( + "channel-type cannot be combined with send logs because " + "ListSmsSendLogForAgent has no ChannelType request filter" + ) + if mobile and not include_logs: + raise AnalyticsError("mobile filter requires --include-logs") + filters = { + key: value + for key, value in { + "SubAccount": sub_account, + "ChannelType": channel_type, + "Signature": signature, + "TemplateId": template_id, + }.items() + if value + } + buckets = time_buckets(window, bucket) + requested_queries = len(buckets) + (max_pages if include_logs else 0) + if requested_queries > MAX_LOGICAL_QUERIES: + raise AnalyticsError( + "aggregate buckets plus send-log pages must not exceed {} logical queries".format( + MAX_LOGICAL_QUERIES + ) + ) + trend: List[Dict[str, Any]] = [] + status = "complete" + for item in buckets: + params = { + "StartTime": item.start_epoch, + # The public Action accepts epoch seconds. Using the inclusive last + # second prevents adjacent local buckets from overlapping. + "EndTime": item.end_epoch - 1, + **filters, + } + result = _require_result( + client.call("ListTotalSendCountStatForAgent", params), + "ListTotalSendCountStatForAgent", + ) + metrics = normalize_stats(result) + if ( + metrics["submission_success_rate"]["status"] == "insufficient_data" + or metrics["receipt_success_rate"]["status"] == "insufficient_data" + or metrics["receipts"]["status"] == "insufficient_data" + ): + status = "insufficient_data" + trend.append({"window": _window_json(item), "metrics": metrics}) + + report: Dict[str, Any] = { + "status": status, + "aggregate_population": "submission_level", + "timezone": TIMEZONE_NAME, + "window": _window_json(window), + "data_cutoff_exclusive": window.end.isoformat(), + "scope": { + "subAccount": sub_account, + "channelType": channel_type, + "signature": signature, + "templateId": template_id, + "sendLogsMobileFilterApplied": bool(mobile), + }, + "bucket": bucket, + "trend": trend, + } + if include_logs: + logs = fetch_send_logs( + client, + window, + sub_account=sub_account, + signature=signature, + template_id=template_id, + mobile=mobile, + page_size=page_size, + max_pages=max_pages, + ) + logs["breakdown"] = group_records(logs["records"], dimension) + logs["dimension"] = dimension + report["send_logs"] = logs + if logs["evidence_status"] != "complete": + report["status"] = "insufficient_data" + return report + + +def _list_items(result: Mapping[str, Any]) -> List[Mapping[str, Any]]: + raw = result.get("List") + if raw is None: + raw = result.get("list") + if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)): + return [] + return [item for item in raw if isinstance(item, Mapping)] + + +def _value(item: Mapping[str, Any], upper: str, lower: str) -> Any: + return item.get(upper) if upper in item else item.get(lower) + + +def _receipt_state(item: Mapping[str, Any]) -> str: + error_code = str(_value(item, "ErrorCode", "errorCode") or "").strip() + if error_code.upper() not in SUCCESS_ERROR_CODES: + return "failure" + receipt_time = _nonnegative_int(_value(item, "ReceiptTime", "receiptTime")) + if receipt_time: + return "success" + return "outstanding" + + +def _safe_record(item: Mapping[str, Any]) -> Dict[str, Any]: + count = _nonnegative_int(_value(item, "Count", "count")) + return { + "messageId": str(_value(item, "MessageId", "messageId") or ""), + "sendTime": _nonnegative_int(_value(item, "SendTime", "sendTime")), + "receiptTime": _nonnegative_int(_value(item, "ReceiptTime", "receiptTime")), + "receiptState": _receipt_state(item), + "errorCode": str(_value(item, "ErrorCode", "errorCode") or "").strip(), + "segments": count if count and count > 0 else 1, + "subAccount": str(_value(item, "SubAccount", "subAccount") or ""), + "signature": str(_value(item, "Signature", "signature") or ""), + "templateId": str(_value(item, "TemplateId", "templateId") or ""), + } + + +def fetch_send_logs( + client: Any, + window: TimeWindow, + *, + sub_account: str, + signature: Optional[str] = None, + template_id: Optional[str] = None, + mobile: Optional[str] = None, + page_size: int = 100, + max_pages: int = 10, +) -> Dict[str, Any]: + """Fetch bounded public send logs with MessageId deduplication.""" + if not 1 <= page_size <= 100: + raise AnalyticsError("page-size must be between 1 and 100") + if not 1 <= max_pages <= 100: + raise AnalyticsError("max-pages must be between 1 and 100") + if not sub_account: + raise AnalyticsError("sub-account is required for send-log analysis") + if mobile and not MOBILE_PATTERN.fullmatch(mobile): + raise AnalyticsError("mobile must be a mainland China mobile number") + if window.end - window.start > MAX_WINDOW: + raise AnalyticsError("send-log window must not exceed 90 days") + + records_by_id: Dict[str, Dict[str, Any]] = {} + anonymous_records: List[Dict[str, Any]] = [] + duplicate_count = 0 + raw_fetched = 0 + total: Optional[int] = None + complete = False + pages_fetched = 0 + limitations: List[str] = [] + + for page in range(1, max_pages + 1): + pages_fetched = page + params: Dict[str, Any] = { + "SubAccount": sub_account, + "FromTime": window.start_epoch, + "ToTime": window.end_epoch - 1, + "Page": page, + "PageSize": page_size, + } + if signature: + params["Signature"] = signature + if template_id: + params["TemplateId"] = template_id + if mobile: + params["Mobile"] = mobile + result = _require_result( + client.call("ListSmsSendLogForAgent", params), + "ListSmsSendLogForAgent", + ) + current_total = _nonnegative_int(result.get("Total")) + if current_total is None: + current_total = _nonnegative_int(result.get("total")) + if current_total is not None: + total = current_total if total is None else max(total, current_total) + items = _list_items(result) + raw_fetched += len(items) + for raw in items: + record = _safe_record(raw) + message_id = record["messageId"] + if not message_id: + anonymous_records.append(record) + if "missing_message_id" not in limitations: + limitations.append("missing_message_id") + elif message_id in records_by_id: + duplicate_count += 1 + else: + records_by_id[message_id] = record + + evidence_count = len(records_by_id) + len(anonymous_records) + if total is not None and evidence_count >= total: + complete = True + break + if not items or len(items) < page_size: + if total is None: + complete = True + else: + limitations.append("service_total_not_reached") + break + + if not complete: + limitations.append("page_limit_reached") + records = list(records_by_id.values()) + anonymous_records + records.sort( + key=lambda row: ( + row["sendTime"] is None, + row["sendTime"] if row["sendTime"] is not None else 0, + row["messageId"], + ) + ) + receipt_states = {"success": 0, "failure": 0, "outstanding": 0} + error_codes: Dict[str, Dict[str, Any]] = {} + for record in records: + receipt_states[record["receiptState"]] += 1 + code = record["errorCode"] + if record["receiptState"] != "failure" or not code: + continue + entry = error_codes.setdefault( + code, + { + "messages": 0, + "segments": 0, + "explanation": "unknown_public_code", + "recommendation": "reconcile_with_the_customer_visible_send_log", + }, + ) + entry["messages"] += 1 + entry["segments"] += record["segments"] + + evidence_status = "complete" + if limitations: + evidence_status = "insufficient_data" + return { + "population": "message_level", + "complete": complete, + "truncated": not complete, + "evidence_status": evidence_status, + "limitations": limitations, + "pages_fetched": pages_fetched, + "service_total": total, + "raw_records_fetched": raw_fetched, + "unique_records": len(records), + "duplicate_count": duplicate_count, + "receipt_states": receipt_states, + "error_codes": dict(sorted(error_codes.items())), + "records": records, + } + + +def group_records( + records: Sequence[Mapping[str, Any]], dimension: str +) -> List[Dict[str, Any]]: + """Group already-sanitized records by an allowed customer dimension.""" + validate_dimension(dimension) + groups: Dict[str, Dict[str, Any]] = {} + field = { + "subAccount": "subAccount", + "signature": "signature", + "template": "templateId", + }.get(dimension) + for record in records: + if dimension == "time": + timestamp = _nonnegative_int(record.get("sendTime")) + if timestamp is None: + value = "unknown" + else: + # Send-log responses use epoch milliseconds even though the + # Action request window uses epoch seconds. Keep compatibility + # with older second-based fixtures and responses. + timestamp_seconds = ( + timestamp / 1000 if timestamp >= 100_000_000_000 else timestamp + ) + value = ( + datetime.datetime.fromtimestamp(timestamp_seconds, tz=SHANGHAI_TZ) + .date() + .isoformat() + ) + else: + value = str(record.get(str(field)) or "unknown") + group = groups.setdefault( + value, + { + "value": value, + "messages": 0, + "segments": 0, + "success": 0, + "failure": 0, + "outstanding": 0, + }, + ) + group["messages"] += 1 + group["segments"] += _nonnegative_int(record.get("segments")) or 1 + state = str(record.get("receiptState")) + if state in {"success", "failure", "outstanding"}: + group[state] += 1 + return [groups[key] for key in sorted(groups)] diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/api_client.py b/server/mcp_server_sms/src/mcp_server_sms/core/api_client.py new file mode 100644 index 00000000..2d1e8f6d --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/api_client.py @@ -0,0 +1,88 @@ +"""Request-scoped V4 SMS client; no CLI, login process, or credential fallback.""" + +import datetime +import time +from typing import Any, Callable + +from .action_contracts import ACTION_REGISTRY +from .api_protocol import ( + ResolvedCredentials, + RequestNotSentError, + TransportResponse, + _error_envelope, + _urllib_transport, + build_signed_request, + emit_json as emit_json, + normalize_response, +) + + +class SmsApiClient: + def __init__( + self, + credentials: ResolvedCredentials, + *, + timeout: float = 15, + transport: Callable[..., TransportResponse] = _urllib_transport, + ): + self.credentials = credentials + self._timeout = timeout + self._transport = transport + + def call( + self, action: str, params: Any, *, preserve_presigned_url: bool = False + ) -> dict: + spec = ACTION_REGISTRY[action] + credentials = self.credentials + secrets = tuple( + filter( + None, + ( + credentials.access_key, + credentials.secret_key, + credentials.session_token, + ), + ) + ) + for attempt in range(3 if spec.read_only else 1): + signed = build_signed_request( + spec, + params, + credentials.access_key, + credentials.secret_key, + datetime.datetime.now(datetime.timezone.utc), + action=action, + session_token=credentials.session_token, + ) + try: + response = self._transport(signed.to_urllib_request(), self._timeout) + result = normalize_response( + action, + spec, + response, + secrets, + preserve_presigned_url=preserve_presigned_url, + ) + except RequestNotSentError: + result = _error_envelope( + action, "network_error", "请求尚未发出", retryable=spec.read_only + ) + except Exception: + # Once dispatch started, an unexpected transport failure cannot prove + # a mutation was not accepted. Never replay it. + result = _error_envelope( + action, + "network_error" if spec.read_only else "outcome_unknown", + "请求结果暂时无法确认", + retryable=spec.read_only, + outcome_unknown=not spec.read_only, + ) + if ( + spec.read_only + and attempt < 2 + and (result.get("error") or {}).get("retryable") + ): + time.sleep(0.5 * 2**attempt) + continue + return result + raise RuntimeError("Unreachable retry state") diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/api_protocol.py b/server/mcp_server_sms/src/mcp_server_sms/core/api_protocol.py new file mode 100644 index 00000000..b0d321b6 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/api_protocol.py @@ -0,0 +1,835 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Licensed under the Apache License, Version 2.0. +# Adapted from byted-sms-sender at c7c49836dff727ca7a2ab297dc9d1ad62c5aec15. + +from __future__ import annotations +import csv +import datetime +import hashlib +import hmac +import io +import json +import re +import socket +from dataclasses import dataclass +from functools import reduce +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) +from urllib import error, parse, request +from .action_contracts import ( + _SHORT_URL_CONFIG_FIELDS, + ACTION_REGISTRY, + COMMON_PAGE_FIELDS, + TEMPLATE_DEMO_CSV_MEDIA_TYPES, + TEMPLATE_FIELDS, + TEMPLATE_PARAM_FIELDS, + TEMPLATE_SCALAR_LIST_FIELDS, + ActionSpec, +) + +DEFAULT_ENDPOINT = "https://sms.volcengineapi.com" +DEFAULT_SERVICE = "volcSMS" +DEFAULT_REGION = "cn-north-1" +RETRYABLE_BUSINESS_ERROR_CODES = frozenset({"1015", "1999"}) +Params = Union[Mapping[str, Any], Sequence[Tuple[str, Any]]] + + +@dataclass(frozen=True) +class SignedRequest: + url: str + method: str + headers: Mapping[str, str] + body: bytes + canonical_query: str + canonical_request: str + string_to_sign: str + authorization: str + + def to_urllib_request(self) -> request.Request: + return request.Request( + self.url, + data=self.body if self.method != "GET" else None, + headers=dict(self.headers), + method=self.method, + ) + + +@dataclass(frozen=True) +class TransportResponse: + status: int + headers: Mapping[str, str] + body: bytes + + +class RequestNotSentError(OSError): + """The transport failed before it could transmit the request.""" + + +class ResponseLostError(OSError): + """The request may have reached the service but no response was observed.""" + + +@dataclass(frozen=True, repr=False) +class ResolvedCredentials: + access_key: str + secret_key: str + session_token: str = "" + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _hmac(key: bytes, value: str) -> bytes: + return hmac.new(key, value.encode("utf-8"), hashlib.sha256).digest() + + +def _query_items(params: Params) -> List[Tuple[str, str]]: + source = params.items() if isinstance(params, Mapping) else params + items: List[Tuple[str, str]] = [] + for key, value in source: + values = value if isinstance(value, (list, tuple)) else (value,) + for item in values: + if item is None: + continue + if isinstance(item, bool): + text = "true" if item else "false" + else: + text = str(item) + items.append((str(key), text)) + return items + + +def _encode_query(items: Iterable[Tuple[str, str]]) -> str: + encoded = [ + (parse.quote(key, safe="-_.~"), parse.quote(value, safe="-_.~")) + for key, value in items + ] + encoded.sort() + return "&".join(("{}={}".format(key, value) for key, value in encoded)) + + +def _signing_key(secret_key: str, date: str, region: str, service: str) -> bytes: + key_date = _hmac(secret_key.encode("utf-8"), date) + key_region = _hmac(key_date, region) + key_service = _hmac(key_region, service) + return _hmac(key_service, "request") + + +def _compact_json(params: Params) -> bytes: + if not isinstance(params, Mapping): + raise ValueError("POST parameters must be a JSON object") + return json.dumps( + params, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + + +def build_signed_request( + spec: ActionSpec, + params: Params, + access_key: str, + secret_key: str, + now: datetime.datetime, + *, + action: Optional[str] = None, + endpoint: str = DEFAULT_ENDPOINT, + region: str = DEFAULT_REGION, + service: str = DEFAULT_SERVICE, + session_token: str = "", +) -> SignedRequest: + """Build a V4-signed request and expose its canonical values for tests.""" + if action is None: + matches = [name for name, value in ACTION_REGISTRY.items() if value is spec] + if len(matches) != 1: + raise ValueError("action is required for an unregistered ActionSpec") + action = matches[0] + parsed_endpoint = parse.urlsplit(endpoint.rstrip("/")) + if parsed_endpoint.scheme != "https" or not parsed_endpoint.netloc: + raise ValueError("VOLCENGINE_SMS_ENDPOINT must be an absolute HTTPS URL") + path = parsed_endpoint.path or "/" + path = parse.quote(parse.unquote(path), safe="/-_.~") + method = spec.method.upper() + if method == "GET": + body = b"" + query_items = [("Action", action), ("Version", spec.version)] + query_items.extend(_query_items(params)) + else: + body = _compact_json(params) + query_items = [("Action", action), ("Version", spec.version)] + canonical_query = _encode_query(query_items) + utc_now = now.astimezone(datetime.timezone.utc) + x_date = utc_now.strftime("%Y%m%dT%H%M%SZ") + short_date = x_date[:8] + body_hash = _sha256(body) + canonical_headers: Dict[str, str] = { + "host": parsed_endpoint.netloc, + "x-content-sha256": body_hash, + "x-date": x_date, + } + if method != "GET": + canonical_headers["content-type"] = "application/json" + if session_token: + canonical_headers["x-security-token"] = session_token + signed_header_names = ";".join(sorted(canonical_headers)) + canonical_header_text = "".join( + ( + "{}:{}\n".format(name, canonical_headers[name].strip()) + for name in sorted(canonical_headers) + ) + ) + canonical_request = "\n".join( + [ + method, + path, + canonical_query, + canonical_header_text, + signed_header_names, + body_hash, + ] + ) + scope = "{}/{}/{}/request".format(short_date, region, service) + string_to_sign = "\n".join( + ["HMAC-SHA256", x_date, scope, _sha256(canonical_request.encode("utf-8"))] + ) + signature = hmac.new( + _signing_key(secret_key, short_date, region, service), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + authorization = ( + "HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}".format( + access_key, scope, signed_header_names, signature + ) + ) + headers = { + "Host": parsed_endpoint.netloc, + "X-Date": x_date, + "X-Content-Sha256": body_hash, + "Authorization": authorization, + } + if method != "GET": + headers["Content-Type"] = "application/json" + if session_token: + headers["X-Security-Token"] = session_token + base_url = parse.urlunsplit( + (parsed_endpoint.scheme, parsed_endpoint.netloc, path, "", "") + ) + return SignedRequest( + url="{}?{}".format(base_url, canonical_query), + method=method, + headers=headers, + body=body, + canonical_query=canonical_query, + canonical_request=canonical_request, + string_to_sign=string_to_sign, + authorization=authorization, + ) + + +def _urllib_transport(req: request.Request, timeout: float) -> TransportResponse: + class NoRedirect(request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + try: + with request.build_opener(NoRedirect()).open(req, timeout=timeout) as response: + return TransportResponse( + int(response.getcode()), dict(response.headers.items()), response.read() + ) + except error.HTTPError as exc: + return TransportResponse( + int(exc.code), + dict(exc.headers.items()) if exc.headers else {}, + exc.read() if hasattr(exc, "read") else b"", + ) + except (socket.timeout, TimeoutError) as exc: + raise ResponseLostError(str(exc)) from exc + except error.URLError as exc: + raise ResponseLostError(str(exc.reason)) from exc + except (ConnectionError, OSError) as exc: + raise ResponseLostError(str(exc)) from exc + + +_PHONE_RE = re.compile("(?]+") +_AUTH_RE = re.compile("(?i)\\bAuthorization\\s*[:=]\\s*[^\\r\\n]+") +_BEARER_RE = re.compile("(?i)\\bBearer\\s+[A-Za-z0-9._~+/\\-=]+") +_SENSITIVE_KEYS = { + "authorization", + "accesskey", + "access_key", + "secretkey", + "secret_key", + "ak", + "sk", + "token", + "sessiontoken", + "xsecuritytoken", + "ticket", + "businesscheckticket", + "operatorcheckticket", + "responsiblecheckticket", + "legalcheckticket", + "identitycard", + "identitycardnumber", + "uploadfilelist", + "materialurl", + "callbackurl", +} + + +def _sanitize_text( + value: str, secrets: Sequence[str], *, strip_url_query: bool = True +) -> str: + safe = reduce( + lambda redacted, literal: ( + redacted.replace(literal, "[REDACTED]") if literal else redacted + ), + secrets, + value, + ) + safe = _AUTH_RE.sub("Authorization: [REDACTED]", safe) + safe = _BEARER_RE.sub("Bearer [REDACTED]", safe) + safe = _PHONE_RE.sub( + lambda match: "{}****{}".format(match.group(1), match.group(3)), safe + ) + + def strip_url(match: re.Match) -> str: + raw = match.group(0) + parsed = parse.urlsplit(raw) + return parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + + if strip_url_query: + safe = _URL_RE.sub(strip_url, safe) + if len(safe) > 1024: + safe = safe[:1021] + "..." + return safe + + +# Identifiers and integrity hashes are protocol data, not free text. A run of +# digits inside a UUID/hash must not be mistaken for a phone number. +_OPAQUE_FIELDS = frozenset( + { + "id", + "ids", + "subaccount", + "subaccounts", + "subaccountid", + "messageid", + "messageids", + "templateid", + "templateids", + "secondtemplateid", + "flowid", + "draftid", + "reviewfileid", + "sha256", + "operationid", + "fileid", + "qualificationid", + "identificationid", + "signatureidentificationid", + "applyid", + "taskid", + "requestid", + "logid", + "digest", + "filesha256", + "contentsha256", + "templatesha256", + "renderedsha256", + "templatecontentsha256", + "renderedcontentsha256", + "filekeyfingerprint", + "ticket", + "businesscheckticket", + "operatorcheckticket", + "responsiblecheckticket", + "legalcheckticket", + "accesskeyid", + "secretaccesskey", + "sessiontoken", + } +) + + +def sanitize_output( + value: Any, *, secrets: Sequence[str] = (), field_name: str = "" +) -> Any: + """Remove private fields and mask free text without rewriting identifiers.""" + if isinstance(value, Mapping): + cleaned = {} + for key, item in value.items(): + canonical = str(key).replace("-", "").replace("_", "").lower() + if canonical in _SENSITIVE_KEYS or canonical in { + "fileurl", + "imageuri", + "personidcard", + }: + continue + cleaned[str(key)] = sanitize_output( + item, secrets=secrets, field_name=str(key) + ) + return cleaned + if isinstance(value, (list, tuple)): + return [ + sanitize_output(item, secrets=secrets, field_name=field_name) + for item in value + ] + if isinstance(value, str): + if field_name.replace("-", "").replace("_", "").lower() in _OPAQUE_FIELDS: + return reduce( + lambda redacted, literal: ( + redacted.replace(literal, "[REDACTED]") if literal else redacted + ), + secrets, + value, + ) + return _sanitize_text(value, secrets) + return value + + +def _filter_result( + value: Any, + allowed_fields: Optional[frozenset], + secrets: Sequence[str], + *, + preserve_presigned_url: bool = False, + field_name: str = "", +) -> Any: + if allowed_fields is None: + return sanitize_output(value, secrets=secrets, field_name=field_name) + if isinstance(value, Mapping): + output: Dict[str, Any] = {} + for key, item in value.items(): + if key not in allowed_fields: + continue + if ( + preserve_presigned_url + and str(key).lower() == "url" + and isinstance(item, str) + ): + output[str(key)] = item + else: + output[str(key)] = _filter_result( + item, + allowed_fields, + secrets, + preserve_presigned_url=preserve_presigned_url, + field_name=str(key), + ) + return output + if isinstance(value, (list, tuple)): + return [ + _filter_result( + item, + allowed_fields, + secrets, + preserve_presigned_url=preserve_presigned_url, + field_name=field_name, + ) + for item in value + ] + return sanitize_output(value, secrets=secrets, field_name=field_name) + + +def _filter_template_result(value: Any, secrets: Sequence[str]) -> Any: + """Filter template results with allowlists scoped to each published path.""" + if not isinstance(value, Mapping): + return {} + + def filter_nested(item: Any, allowed: Set[str]) -> Any: + if isinstance(item, Mapping): + return { + str(key): sanitize_output(nested, secrets=secrets, field_name=str(key)) + for key, nested in item.items() + if key in allowed + and (not isinstance(nested, (Mapping, list, tuple, set))) + } + if isinstance(item, (list, tuple)): + return [filter_nested(nested, allowed) for nested in item] + return sanitize_output(item, secrets=secrets) + + def filter_item(item: Any) -> Any: + if not isinstance(item, Mapping): + return sanitize_output(item, secrets=secrets) + output: Dict[str, Any] = {} + for key, nested in item.items(): + if key not in TEMPLATE_FIELDS: + continue + if key in {"TemplateParams", "templateParams"}: + output[str(key)] = filter_nested(nested, TEMPLATE_PARAM_FIELDS) + elif key in {"ShortUrlConfig", "shortUrlConfig"}: + output[str(key)] = filter_nested(nested, _SHORT_URL_CONFIG_FIELDS) + elif key in TEMPLATE_SCALAR_LIST_FIELDS: + values = nested if isinstance(nested, (list, tuple)) else () + output[str(key)] = [ + sanitize_output(value, secrets=secrets, field_name=str(key)) + for value in values + if not isinstance(value, (Mapping, list, tuple, set)) + ] + elif isinstance(nested, (Mapping, list, tuple, set)): + continue + else: + output[str(key)] = sanitize_output( + nested, secrets=secrets, field_name=str(key) + ) + return output + + output: Dict[str, Any] = {} + for key, item in value.items(): + if key in {"List", "list", "Items", "items"}: + values = item if isinstance(item, (list, tuple)) else () + output[str(key)] = [ + filter_item(nested) for nested in values if isinstance(nested, Mapping) + ] + elif key in COMMON_PAGE_FIELDS and ( + not isinstance(item, (Mapping, list, tuple, set)) + ): + output[str(key)] = sanitize_output(item, secrets=secrets) + elif key in TEMPLATE_FIELDS: + filtered = filter_item({key: item}) + if key in filtered: + output[str(key)] = filtered[key] + return output + + +def _filter_message_group_detail(value: Any, secrets: Sequence[str]) -> Any: + """Filter GetSubAccountDetail with field allowlists scoped by JSON path.""" + if not isinstance(value, Mapping): + return {} + + def is_scalar(item: Any) -> bool: + return not isinstance(item, (Mapping, list, tuple, set)) + + output: Dict[str, Any] = {} + for key in ("subAccountId", "subAccountName", "status"): + if key in value and is_scalar(value[key]): + output[key] = sanitize_output( + value[key], secrets=secrets, field_name=str(key) + ) + mapping_key = "channelTypeToIndustryConfig" + if mapping_key in value: + raw_mappings = value[mapping_key] + mappings = raw_mappings if isinstance(raw_mappings, (list, tuple)) else () + output[mapping_key] = [ + { + key: sanitize_output(item[key], secrets=secrets) + for key in ("channelType", "channelTypeCn", "industry", "industryCn") + if key in item and is_scalar(item[key]) + } + for item in mappings + if isinstance(item, Mapping) + ] + return output + + +def _header(headers: Mapping[str, str], name: str) -> Optional[str]: + lowered = name.lower() + for key, value in headers.items(): + if key.lower() == lowered: + return value + return None + + +def _request_id(payload: Any, headers: Mapping[str, str]) -> Optional[str]: + if isinstance(payload, Mapping): + metadata = payload.get("ResponseMetadata") + if isinstance(metadata, Mapping): + value = metadata.get("RequestId") or metadata.get("RequestID") + if value is not None: + return str(value) + return _header(headers, "X-Tt-Logid") or _header(headers, "X-Request-Id") + + +def _business_error_code(response: TransportResponse) -> str: + try: + payload = json.loads(response.body.decode("utf-8")) if response.body else {} + except (UnicodeDecodeError, json.JSONDecodeError): + return "" + metadata = payload.get("ResponseMetadata") if isinstance(payload, Mapping) else None + error_value = ( + metadata.get("Error") + if isinstance(metadata, Mapping) and isinstance(metadata.get("Error"), Mapping) + else None + ) + return str(error_value.get("Code") or "") if error_value is not None else "" + + +def _filename_from_content_disposition(value: Optional[str]) -> str: + if not value: + return "" + for part in value.split(";"): + key, separator, raw = part.strip().partition("=") + if not separator or key.lower() not in {"filename", "filename*"}: + continue + encoded = raw.strip().strip('"') + if key.lower() == "filename*" and "''" in encoded: + encoded = encoded.split("''", 1)[1] + decoded = parse.unquote(encoded).replace("\\", "/").rsplit("/", 1)[-1] + if decoded: + return decoded + return "" + + +def _error_envelope( + action: str, + code: str, + message: str, + *, + request_id: Optional[str] = None, + retryable: bool = False, + outcome_unknown: bool = False, + secrets: Sequence[str] = (), + remediation: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + error_value: Dict[str, Any] = { + "code": code, + "message": _sanitize_text(message, secrets), + "retryable": retryable, + "outcome_unknown": outcome_unknown, + } + if remediation: + error_value["remediation"] = sanitize_output(dict(remediation), secrets=secrets) + return { + "success": False, + "action": action, + "request_id": _sanitize_text(request_id, secrets) if request_id else None, + "result": None, + "error": error_value, + } + + +def _invalid_success_response( + action: str, + spec: ActionSpec, + message: str, + *, + request_id: Optional[str], + secrets: Sequence[str], +) -> Dict[str, Any]: + """Preserve an ambiguous write outcome when a 2xx body is unusable.""" + if spec.read_only: + return _error_envelope( + action, "invalid_response", message, request_id=request_id, secrets=secrets + ) + return _error_envelope( + action, + "outcome_unknown", + message, + request_id=request_id, + outcome_unknown=True, + secrets=secrets, + ) + + +def _result_contract_error(spec: ActionSpec, result: Any) -> Optional[str]: + """Return a structural response error without treating zero as missing.""" + if not spec.required_result_fields and (not spec.required_result_any): + return None + if not isinstance(result, Mapping): + return "Service response Result must be a JSON object" + missing = sorted( + (field for field in spec.required_result_fields if field not in result) + ) + if missing: + return "Service response Result is missing required field(s): {}".format( + ", ".join(missing) + ) + if spec.required_result_any and ( + not any((field in result for field in spec.required_result_any)) + ): + return "Service response Result contains none of the expected fields" + return None + + +def _normalize_template_demo_csv( + action: str, spec: ActionSpec, response: TransportResponse, secrets: Sequence[str] +) -> Dict[str, Any]: + request_id = _request_id({}, response.headers) + content_type = _header(response.headers, "Content-Type") or "" + media_type = content_type.partition(";")[0].strip().lower() + if not response.body: + return _error_envelope( + action, + "invalid_response", + "TemplateUploadDemo returned an empty response body", + request_id=request_id, + secrets=secrets, + ) + if media_type not in TEMPLATE_DEMO_CSV_MEDIA_TYPES: + return _error_envelope( + action, + "invalid_response", + "TemplateUploadDemo response was not an expected CSV file", + request_id=request_id, + secrets=secrets, + ) + try: + value = response.body.decode("utf-8-sig") + except UnicodeDecodeError: + return _error_envelope( + action, + "invalid_response", + "TemplateUploadDemo CSV was not valid UTF-8", + request_id=request_id, + secrets=secrets, + ) + try: + first_row = next(csv.reader(io.StringIO(value, newline=""), strict=True)) + except (csv.Error, StopIteration): + first_row = [] + if not first_row or first_row[0] != "phone": + return _error_envelope( + action, + "invalid_response", + "TemplateUploadDemo CSV must use phone as its first column", + request_id=request_id, + secrets=secrets, + ) + demo = { + "fileName": _filename_from_content_disposition( + _header(response.headers, "Content-Disposition") + ) + or "TemplateUploadDemo.csv", + "value": value, + "contentType": content_type, + "size": len(response.body), + } + return { + "success": True, + "action": action, + "request_id": _sanitize_text(request_id, secrets) if request_id else None, + "result": _filter_result(demo, spec.result_fields, secrets), + "error": None, + } + + +def emit_json(value: Any, *, secrets: Sequence[str] = ()) -> str: + """Return stable JSON suitable for stdout/stderr without leaking secrets.""" + return json.dumps( + sanitize_output(value, secrets=secrets), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def normalize_response( + action: str, + spec: ActionSpec, + response: TransportResponse, + secrets: Sequence[str], + *, + preserve_presigned_url: bool = False, +) -> Dict[str, Any]: + if ( + action == "TemplateUploadDemo" + and 200 <= response.status < 300 + and (not response.body) + ): + return _normalize_template_demo_csv(action, spec, response, secrets) + try: + payload = json.loads(response.body.decode("utf-8")) if response.body else {} + except (UnicodeDecodeError, json.JSONDecodeError): + if action == "TemplateUploadDemo" and 200 <= response.status < 300: + return _normalize_template_demo_csv(action, spec, response, secrets) + if 200 <= response.status < 300: + return _invalid_success_response( + action, + spec, + "Service response was not valid JSON", + request_id=_request_id({}, response.headers), + secrets=secrets, + ) + return _error_envelope( + action, + "http_{}".format(response.status), + "HTTP {} returned a non-JSON response".format(response.status), + request_id=_request_id({}, response.headers), + retryable=spec.read_only + and (response.status == 429 or 500 <= response.status <= 599), + secrets=secrets, + ) + request_id = _request_id(payload, response.headers) + metadata = payload.get("ResponseMetadata") if isinstance(payload, Mapping) else None + business_error = ( + metadata.get("Error") + if isinstance(metadata, Mapping) and isinstance(metadata.get("Error"), Mapping) + else None + ) + if business_error is not None: + business_code = str(business_error.get("Code") or "service_error") + return _error_envelope( + action, + business_code, + "Service returned error {}".format(business_code), + request_id=request_id, + retryable=spec.read_only + and ( + response.status == 429 + or 500 <= response.status <= 599 + or business_code in RETRYABLE_BUSINESS_ERROR_CODES + ), + secrets=secrets, + ) + if response.status >= 500 and not spec.read_only: + return _error_envelope( + action, + "outcome_unknown", + "写请求结果暂时无法确认", + request_id=request_id, + outcome_unknown=True, + secrets=secrets, + ) + if not 200 <= response.status < 300: + return _error_envelope( + action, + "http_{}".format(response.status), + "HTTP {}".format(response.status), + request_id=request_id, + retryable=spec.read_only + and (response.status == 429 or 500 <= response.status <= 599), + secrets=secrets, + ) + if not isinstance(payload, Mapping): + return _invalid_success_response( + action, + spec, + "Service response root must be a JSON object", + request_id=request_id, + secrets=secrets, + ) + if action == "GetSubAccountDetail": + result = _filter_message_group_detail(payload.get("Result"), secrets) + elif action in {"ListSmsTemplateForAgent", "ListSecondTemplate"}: + result = _filter_template_result(payload.get("Result"), secrets) + else: + result = _filter_result( + payload.get("Result"), + spec.result_fields, + secrets, + preserve_presigned_url=preserve_presigned_url, + ) + contract_error = _result_contract_error(spec, result) + if contract_error is not None: + return _invalid_success_response( + action, spec, contract_error, request_id=request_id, secrets=secrets + ) + return { + "success": True, + "action": action, + "request_id": _sanitize_text(request_id, secrets) if request_id else None, + "result": result, + "error": None, + } diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/qualification.py b/server/mcp_server_sms/src/mcp_server_sms/core/qualification.py new file mode 100644 index 00000000..c540c687 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/qualification.py @@ -0,0 +1,842 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Licensed under the Apache License, Version 2.0. +"""Qualification business rules independent of MCP, HTTP and user interfaces.""" + +from __future__ import annotations +import datetime +from copy import deepcopy +from typing import Literal +from pydantic import BaseModel, ConfigDict, Field, ValidationError +import re +import time +from .qualification_upload import ocr_business_certificate_image +from typing import Any, Dict, Mapping +from .qualification_upload import ( + BUSINESS_CHECK_SKIP_TICKET, + MAX_IMAGE_BYTES, + QualificationUploadError, + check_mobile_verification_code, + check_signature_qualification_information, + create_signature_qualification, + get_account_identity, + get_account_ident_rank, + normalize_check_status, + ocr_identity_document_images, + send_mobile_verification_code, + upload_qualification_file_bytes, +) + +MAX_JSON_BYTES = 64 * 1024 +PERSON_FILE_TYPES = {"operator": (8, 9), "responsible": (10, 11), "legal": (18, 19)} +LEGAL_CERTIFICATE_TYPES = frozenset({0, 1, 2, 3, 4, 9}) +LEGAL_DOCUMENT_FILE_TYPES = {1: 12, 2: 13, 3: 14, 4: 15, 9: 6} +PERSON_LABELS = {"operator": "经办人", "responsible": "责任人", "legal": "法人"} + + +class PrivateData(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + +class BusinessData(PrivateData): + businessCertificateType: Literal[1, 4, 6, 7] + businessCertificateName: str = Field(min_length=1, max_length=256) + unifiedSocialCreditIdentifier: str = Field(min_length=1, max_length=64) + legalPersonName: str = Field(min_length=1, max_length=256) + businessCertificateValidityPeriodStart: str + businessCertificateValidityPeriodEnd: str + + +class PersonData(PrivateData): + certificateType: Literal[0, 1, 2, 3, 4, 9] = 0 + personName: str = "" + personIDCard: str = "" + personMobile: str = "" + + +def new_state(client): + rank = get_account_ident_rank(client) + identity = get_account_identity(client) + return { + "rank": rank, + "accountBusinessName": identity["businessName"], + "userType": identity["userType"], + "mobileVerificationRequired": identity["userType"] == "smb", + "revision": 0, + "materialName": "", + "purpose": 1, + "authorizee": "", + "sameOperator": None, + "sections": {}, + "pending": {}, + "checks": {}, + "mobileVerifications": {}, + "done": False, + "result": None, + } + + +def requirements(state): + return { + "accountRules": state["rank"], + "accountBusinessName": state["accountBusinessName"], + "mobileVerificationRequired": state["mobileVerificationRequired"], + "imageTypes": ["image/jpeg", "image/png"], + "maxImageBytes": MAX_IMAGE_BYTES, + "legalPersonOptional": True, + } + + +def public_state(state): + if state["done"]: + return {"status": "finished", "result": state["result"]} + sections = state["sections"] + checks = { + role: _public_check(state, role) for role in ("business", *PERSON_FILE_TYPES) + } + if state["sameOperator"] is True: + checks["responsible"] = dict(checks["operator"]) + missing = [] + if not state["materialName"]: + missing.append("base_information") + for role in ("business", "operator"): + if role not in sections: + missing.append(role + "_information") + if state["sameOperator"] is None: + missing.append("responsible_mode") + elif not state["sameOperator"] and "responsible" not in sections: + missing.append("responsible_information") + for role in ("business", "operator", "responsible"): + if not checks[role]["canContinue"]: + missing.append(role + "_check") + legal = sections.get("legal") + if ( + legal + and legal["certificateType"] == 0 + and legal["personIDCard"] + and not checks["legal"]["matched"] + ): + missing.append("legal_check") + verified = { + role: _mobile_verified(state, role) for role in ("operator", "responsible") + } + missing.extend( + role + "_mobile_verification" for role, passed in verified.items() if not passed + ) + if state["purpose"] == 2: + if not state["authorizee"]: + missing.append("account_business_name") + if state["rank"]["needOtherUseCheck"] and "powerOfAttorney" not in sections: + missing.append("power_of_attorney") + if state["pending"]: + missing.append("confirm_uploaded_information") + return { + "status": "draft", + "revision": state["revision"], + "materialName": state["materialName"], + "purpose": state["purpose"], + "sameOperator": state["sameOperator"], + "savedSections": list(sections), + "pendingSections": list(state["pending"]), + "checks": checks, + "mobileVerifications": verified, + "missing": missing, + "readyForPreview": not missing, + } + + +class QualificationDraft: + """Serialized business operations; the caller owns persistence and authorization.""" + + def __init__(self, client, state): + self.client, self.state = client, state + + def set_base(self, name, purpose): + name = _text(name, maximum=20) + if purpose not in (1, 2): + raise ValueError("资质用途必须为自用或他用") + state = self.state + state.update( + materialName=name, + purpose=purpose, + authorizee=state["accountBusinessName"] if purpose == 2 else "", + ) + if purpose == 1: + for target in ("powerOfAttorney", "otherMaterials"): + state["sections"].pop(target, None) + state["pending"].pop(target, None) + state["revision"] += 1 + + def upload_document( + self, + content, + content_type, + target, + certificate_type=None, + side=None, + index=None, + replace=False, + ): + state = self.state + if target == "business": + valid = ( + certificate_type in (1, 4, 6, 7) + and side is None + and index is None + and not replace + ) + elif target in PERSON_FILE_TYPES: + if target == "legal" and certificate_type in LEGAL_DOCUMENT_FILE_TYPES: + valid = side is None and index in (0, 1) + else: + valid = ( + certificate_type == 0 + and side in ("front", "back") + and index is None + and not replace + ) + elif target == "powerOfAttorney": + valid = ( + state["purpose"] == 2 + and state["rank"]["needOtherUseCheck"] + and certificate_type is None + and side is None + and index is None + and not replace + ) + elif target == "otherMaterials": + valid = ( + state["purpose"] == 2 + and certificate_type is None + and side is None + and index in range(5) + ) + else: + valid = False + if not valid: + raise ValueError("材料类型、证件类型或图片位置与当前申请不匹配") + if target == "otherMaterials": + documents = [] if replace else state["sections"].get(target, []) + if index > len(documents): + raise ValueError("材料序号必须连续") + elif target == "legal" and certificate_type in LEGAL_DOCUMENT_FILE_TYPES: + pending = state["pending"].get("legal", {}) + documents = ( + [] + if replace or pending.get("certificateType") != certificate_type + else pending.get("documents", []) + ) + if index > len(documents): + raise ValueError("材料序号必须连续") + uploaded = upload_qualification_file_bytes(self.client, content, content_type) + if target == "powerOfAttorney": + state["sections"][target] = uploaded + elif target == "otherMaterials": + existing = [] if replace else state["sections"].get(target, []) + materials = list(existing) + if index == len(materials): + materials.append(uploaded) + else: + materials[index] = uploaded + state["sections"][target] = materials + elif target == "business": + state["pending"][target] = {**uploaded, "certificateType": certificate_type} + else: + pending = state["pending"].setdefault(target, {}) + if pending and pending.get("certificateType") != certificate_type: + pending.clear() + pending["certificateType"] = certificate_type + if certificate_type == 0: + pending.setdefault("imageUris", {})[side] = uploaded["imageUri"] + pending.setdefault("imageSuffixes", {})[side] = uploaded["imageSuffix"] + else: + documents = [] if replace else pending.get("documents", []) + if index == len(documents): + documents.append(uploaded) + else: + documents[index] = uploaded + pending["documents"] = documents + state["checks"].pop(target, None) + state["mobileVerifications"].pop(target, None) + state["revision"] += 1 + return {"uploaded": True, "target": target} + + def recognize_document(self, target): + pending = self.state["pending"].get(target) + if not pending: + raise ValueError("请先上传需要识别的材料") + if target == "business": + result = ocr_business_certificate_image( + self.client, pending["imageUri"], pending["certificateType"] + ) + elif ( + target in PERSON_FILE_TYPES + and pending["certificateType"] == 0 + and _has_identity_image_pair(pending) + ): + result = ocr_identity_document_images( + self.client, + pending["imageUris"]["front"], + pending["imageUris"]["back"], + image_suffixes=pending["imageSuffixes"], + )["person"] + else: + raise ValueError("当前证件不支持 OCR,或身份证正反面尚未齐全") + # Raw OCR results travel through the private file exchange, never a tool result. + return result + + def set_information(self, target, document): + model = BusinessData if target == "business" else PersonData + if target not in ("business", *PERSON_FILE_TYPES): + raise ValueError("不支持的信息类型") + try: + values = model.model_validate(document).model_dump() + except ValidationError as error: + fields = [ + ".".join(map(str, item["loc"])) + for item in error.errors(include_input=False) + ] + raise ValueError("材料字段格式不正确:" + ", ".join(fields)) from None + state = self.state + sections = state["sections"] + pending = state["pending"].get(target) + existing = sections.get(target) + if target == "business": + start = _date(values["businessCertificateValidityPeriodStart"]) + end = _date(values["businessCertificateValidityPeriodEnd"]) + if end < start: + raise ValueError("营业证件有效期结束时间早于开始时间") + source = pending if pending is not None else existing + if state["rank"]["needBusinessCertificateImage"] and not ( + source and source.get("imageUri") + ): + raise ValueError("当前账号要求提供营业证件图片") + if source and source.get("imageUri"): + source_type = source.get( + "certificateType", source.get("businessCertificateType") + ) + if source_type != values["businessCertificateType"]: + raise ValueError("营业证件类型已变化,请重新上传对应图片") + values.update( + imageUri=source["imageUri"], imageSuffix=source["imageSuffix"] + ) + legal = sections.get("legal") + if legal and legal["personName"] != values["legalPersonName"]: + sections.pop("legal", None) + state["pending"].pop("legal", None) + state["checks"].pop("legal", None) + else: + certificate_type = values["certificateType"] + if target != "legal" and certificate_type != 0: + raise ValueError("经办人和责任人仅支持居民身份证") + if target == "responsible" and state["sameOperator"] is True: + raise ValueError("当前责任人复用经办人,请先切换为不同人员") + values["personName"] = ( + _legal_person_name(sections.get("business"), values["personName"]) + if target == "legal" + else _text(values["personName"]) + ) + values["personIDCard"] = ( + _id_card(values["personIDCard"], required=target != "legal") + if certificate_type == 0 + else _optional_text(values["personIDCard"], maximum=64) + ) + values["personMobile"] = _mobile( + values["personMobile"], + required=_person_requirement(state["rank"], target, "mobile"), + ) + source = pending if pending is not None else existing + if source and source.get("certificateType") != certificate_type: + source = None + if certificate_type == 0: + if ( + source + and source.get("imageUris") + and not _has_identity_image_pair(source) + ): + raise ValueError("身份证图片需要同时提供正反面") + if _person_requirement( + state["rank"], target, "image" + ) and not _has_identity_image_pair(source): + raise ValueError("当前账号要求提供该人员的身份证正反面") + if _has_identity_image_pair(source): + values.update( + imageUris=dict(source["imageUris"]), + imageSuffixes=dict(source["imageSuffixes"]), + ) + elif source and source.get("documents"): + values["documents"] = deepcopy(source["documents"]) + state["mobileVerifications"].pop(target, None) + sections[target] = values + state["pending"].pop(target, None) + state["checks"].pop(target, None) + if target == "operator" and state["sameOperator"] is True: + sections.pop("responsible", None) + state["checks"].pop("responsible", None) + state["revision"] += 1 + + def clear_optional_legal(self): + for key in ("sections", "pending", "checks"): + self.state[key].pop("legal", None) + self.state["revision"] += 1 + + def set_responsible_mode(self, same_operator): + state = self.state + operator = state["sections"].get("operator") + if not operator: + raise ValueError("请先保存经办人信息") + if same_operator: + if _person_requirement( + state["rank"], "responsible", "image" + ) and not _has_identity_image_pair(operator): + raise ValueError("复用责任人前需要补齐经办人身份证图片") + if ( + _person_requirement(state["rank"], "responsible", "mobile") + and not operator["personMobile"] + ): + raise ValueError("复用责任人前需要补齐经办人手机号") + for key in ("sections", "pending", "checks", "mobileVerifications"): + state[key].pop("responsible", None) + state["sameOperator"] = same_operator + state["revision"] += 1 + + def check_information(self, target): + state = self.state + if target in state["pending"]: + raise ValueError("请先确认新上传材料对应的信息") + result = check_signature_qualification_information( + self.client, target, _verification_params(state, target) + ) + state["checks"][target] = result + if target in ("operator", "responsible"): + state["mobileVerifications"].pop(target, None) + state["revision"] += 1 + return {"target": target, "check": _public_check(state, target)} + + def code_recipient(self, role): + state = self.state + if ( + role not in ("operator", "responsible") + or not state["mobileVerificationRequired"] + or (role == "responsible" and state["sameOperator"] is True) + ): + raise ValueError("当前人员无需独立进行手机号验证码校验") + person = state["sections"].get(role) + check = state["checks"].get(role) + if ( + not person + or not person["personMobile"] + or not check + or not check["matched"] + ): + raise ValueError("请先完成该人员的信息校验并提供手机号") + return person["personMobile"] + + def send_code(self, role): + mobile = self.code_recipient(role) + previous = self.state["mobileVerifications"].get(role) + if previous and time.time() - previous.get("sentAt", 0) < 60: + raise ValueError("验证码发送间隔至少为 60 秒") + message_id = send_mobile_verification_code(self.client, mobile, role) + self.state["mobileVerifications"][role] = { + "mobile": mobile, + "messageId": message_id, + "verified": False, + "sentAt": time.time(), + } + self.state["revision"] += 1 + return {"status": "verification_code_sent"} + + def verify_code(self, role, code): + mobile = self.code_recipient(role) + verification = self.state["mobileVerifications"].get(role) + if not verification or verification["mobile"] != mobile: + raise ValueError("请先获取当前手机号的验证码") + check_mobile_verification_code(self.client, mobile, code, role) + verification["verified"] = True + self.state["revision"] += 1 + return {"status": "mobile_verified"} + + def preview(self): + status = public_state(self.state) + if not status.get("readyForPreview"): + raise ValueError( + "申请信息或校验尚未完成:" + ", ".join(status.get("missing", [])) + ) + return _preview(self.state) + + def submit(self, expected_revision): + if self.state["revision"] != expected_revision: + raise ValueError("材料已变化,请重新生成并确认预览") + self.preview() + try: + result = create_signature_qualification( + self.client, _submission_payload(self.state) + ) + except QualificationUploadError as error: + if error.outcome_unknown: + self.state.update( + done=True, + result={ + "status": "qualification_submission_outcome_unknown", + "outcomeUnknown": True, + "requestId": error.request_id, + "logId": error.log_id, + }, + ) + raise + self.state.update(done=True, result={**result, "revision": expected_revision}) + return result + + +def _text(value: Any, *, maximum: int = 256) -> str: + if not isinstance(value, str): + raise ValueError("文本字段需要字符串") + if not value or value.isspace() or len(value) > maximum: + raise ValueError("文本字段不能为空或超过允许长度") + return value + + +def _optional_text(value: Any, *, maximum: int = 256) -> str: + if value is None: + return "" + if not isinstance(value, str): + raise ValueError + if len(value) > maximum: + raise ValueError + return value + + +def _date(value: Any) -> str: + value = _text(value, maximum=10) + try: + parsed = datetime.date.fromisoformat(value) + except ValueError: + raise ValueError("有效期需要 YYYY-MM-DD 格式") from None + if parsed.isoformat() != value: + raise ValueError("有效期需要 YYYY-MM-DD 格式") + return value + + +def _id_card(value: Any, *, required: bool = True) -> str: + if value is None: + value = "" + if not isinstance(value, str): + raise ValueError + if not value and (not required): + return "" + if not re.fullmatch("[0-9]{17}[0-9X]", value): + raise ValueError + return value + + +def _mobile(value: Any, *, required: bool) -> str: + if value is None: + value = "" + if not isinstance(value, str): + raise ValueError + if value and (not re.fullmatch("1[3-9][0-9]{9}", value)): + raise ValueError + if required and (not value): + raise ValueError + return value + + +def _person_requirement(rank: Mapping[str, Any], role: str, kind: str) -> bool: + """Return the deterministic fields required before a role can be saved.""" + if kind not in ("image", "mobile"): + raise ValueError + if role == "legal": + return False + if role not in ("operator", "responsible"): + raise ValueError + prefix = "Operator" if role == "operator" else "Responsible" + if kind == "image": + return bool(rank.get("need" + prefix + "Image")) + return bool(rank.get(role + "ThreeElement") or rank.get("need" + prefix + "Mobile")) + + +def _legal_person_name(business: Any, submitted: Any) -> str: + """Keep the business certificate as the sole source of the legal name.""" + if not isinstance(business, Mapping): + raise QualificationUploadError( + "business_info_required", "请先保存营业证件信息。" + ) + expected = _text(business.get("legalPersonName")) + try: + actual = _text(submitted) + except ValueError as error: + raise QualificationUploadError( + "legal_name_mismatch", "法人姓名必须与营业证件一致。" + ) from error + if actual != expected: + raise QualificationUploadError( + "legal_name_mismatch", "法人姓名必须与营业证件一致。" + ) + return expected + + +def _public_check(state: Mapping[str, Any], target: str) -> Dict[str, Any]: + check = state.get("checks", {}).get(target) + if not isinstance(check, Mapping): + return {"attempted": False, "matched": False, "canContinue": False} + return { + "attempted": True, + "matched": bool(check.get("matched")), + "canContinue": bool(check.get("canContinue")), + "status": normalize_check_status(check.get("status")), + "forceSkip": check.get("ticket") == BUSINESS_CHECK_SKIP_TICKET, + } + + +def _verification_params(state: Mapping[str, Any], target: str) -> Dict[str, Any]: + sections = state["sections"] + if target == "business": + business = sections.get("business") + if not isinstance(business, Mapping): + raise QualificationUploadError( + "qualification_check_required", "请先保存营业证件信息。" + ) + return { + "businessCertificateName": business["businessCertificateName"], + "unifiedSocialCreditIdentifier": business["unifiedSocialCreditIdentifier"], + "legalPersonName": business["legalPersonName"], + } + if target not in ("legal", "operator", "responsible"): + raise QualificationUploadError( + "qualification_check_invalid", "不支持当前信息校验。" + ) + if target == "responsible" and state.get("sameOperator") is True: + raise QualificationUploadError( + "qualification_check_not_needed", "责任人与经办人相同,无需重复校验。" + ) + person = sections.get(target) + if not isinstance(person, Mapping): + raise QualificationUploadError( + "qualification_check_required", + "请先保存{}信息。".format(PERSON_LABELS[target]), + ) + mobile_required = target != "legal" and bool( + state["rank"].get(target + "ThreeElement") + ) + return { + "personName": person["personName"], + "personIDCard": person["personIDCard"], + "personMobile": person["personMobile"] if mobile_required else "", + } + + +def _has_identity_image_pair(section: Any) -> bool: + if not isinstance(section, Mapping): + return False + image_uris = section.get("imageUris") + return isinstance(image_uris, Mapping) and bool( + image_uris.get("front") and image_uris.get("back") + ) + + +def _mobile_verification_needed(state: Mapping[str, Any], role: str) -> bool: + if not state.get("mobileVerificationRequired"): + return False + person = state.get("sections", {}).get(role) + return isinstance(person, Mapping) and bool(person.get("personMobile")) + + +def _mobile_verified(state: Mapping[str, Any], role: str) -> bool: + if role == "responsible" and state.get("sameOperator") is True: + role = "operator" + if not _mobile_verification_needed(state, role): + return True + person = state.get("sections", {}).get(role) + verification = state.get("mobileVerifications", {}).get(role) + return bool( + isinstance(person, Mapping) + and isinstance(verification, Mapping) + and (verification.get("verified") is True) + and (verification.get("mobile") == person.get("personMobile")) + ) + + +def _person_payload(section: Mapping[str, Any], role: str) -> Dict[str, Any]: + front_type, back_type = PERSON_FILE_TYPES[role] + certificate_type = int(section.get("certificateType") or 0) + payload = { + "certificateType": certificate_type, + "personCertificate": [], + "personName": section["personName"], + "personIDCard": section["personIDCard"], + "personMobile": section["personMobile"], + } + uris = section.get("imageUris") + suffixes = section.get("imageSuffixes") + if isinstance(uris, Mapping) and isinstance(suffixes, Mapping): + payload["personCertificate"] = [ + { + "fileType": front_type, + "fileContent": uris["front"], + "fileSuffix": suffixes["front"], + }, + { + "fileType": back_type, + "fileContent": uris["back"], + "fileSuffix": suffixes["back"], + }, + ] + documents = section.get("documents") + if ( + role == "legal" + and certificate_type in LEGAL_DOCUMENT_FILE_TYPES + and isinstance(documents, list) + ): + payload["personCertificate"] = [ + { + "fileType": LEGAL_DOCUMENT_FILE_TYPES[certificate_type], + "fileContent": item["imageUri"], + "fileSuffix": item["imageSuffix"], + } + for item in documents + if isinstance(item, Mapping) + and item.get("imageUri") + and item.get("imageSuffix") + ] + return payload + + +def _submission_payload(state: Mapping[str, Any]) -> Dict[str, Any]: + sections = state["sections"] + business = sections["business"] + checks = state["checks"] + operator_ticket = checks["operator"]["ticket"] + responsible_ticket = ( + operator_ticket + if state.get("sameOperator") is True + else checks["responsible"]["ticket"] + ) + payload: Dict[str, Any] = { + "id": 0, + "purpose": state["purpose"], + "materialName": state["materialName"], + "businessInfo": { + "businessCertificateType": business["businessCertificateType"], + "businessCertificate": {}, + "businessCertificateName": business["businessCertificateName"], + "unifiedSocialCreditIdentifier": business["unifiedSocialCreditIdentifier"], + "businessCertificateValidityPeriodStart": business[ + "businessCertificateValidityPeriodStart" + ], + "businessCertificateValidityPeriodEnd": business[ + "businessCertificateValidityPeriodEnd" + ], + "legalPersonName": business["legalPersonName"], + }, + "operatorPerson": _person_payload(sections["operator"], "operator"), + "responsiblePersonInfo": _person_payload( + sections["operator"] + if state.get("sameOperator") is True + else sections["responsible"], + "responsible", + ), + "powerOfAttorney": [], + "otherMaterials": [], + "effectSignatures": [], + "from": "vconsole", + "sameOperator": state.get("sameOperator") is True, + "businessCheckTicket": checks["business"]["ticket"], + "operatorCheckTicket": operator_ticket, + "responsibleCheckTicket": responsible_ticket, + } + legal = sections.get("legal") + if isinstance(legal, Mapping): + payload["legalPerson"] = _person_payload(legal, "legal") + legal_check = checks.get("legal") + if isinstance(legal_check, Mapping) and legal_check.get("ticket"): + payload["legalCheckTicket"] = legal_check["ticket"] + if business.get("imageUri"): + payload["businessInfo"]["businessCertificate"] = { + "fileType": business["businessCertificateType"], + "fileContent": business["imageUri"], + "fileSuffix": business["imageSuffix"], + } + other_materials = sections.get("otherMaterials") + if isinstance(other_materials, list): + payload["otherMaterials"] = [ + { + "fileType": 6, + "fileContent": item["imageUri"], + "fileSuffix": item["imageSuffix"], + } + for item in other_materials + if isinstance(item, Mapping) + and item.get("imageUri") + and item.get("imageSuffix") + ] + rank = state["rank"] + if state["purpose"] == 2: + payload["authorizer"] = business["businessCertificateName"] + payload["authorizee"] = state["authorizee"] + if state["purpose"] == 2 and rank.get("needOtherUseCheck"): + power = sections["powerOfAttorney"] + payload["powerOfAttorney"] = [ + { + "fileType": 5, + "fileContent": power["imageUri"], + "fileSuffix": power["imageSuffix"], + } + ] + return payload + + +def _preview(state: Mapping[str, Any]) -> Dict[str, Any]: + sections = state["sections"] + business = sections["business"] + people = {} + for role in PERSON_FILE_TYPES: + if role == "responsible" and state.get("sameOperator") is True: + person = sections.get("operator") + else: + person = sections.get(role) + if isinstance(person, Mapping): + people[role] = { + "label": PERSON_LABELS[role], + "personName": person["personName"], + "personIDCard": person["personIDCard"], + "personMobile": person["personMobile"], + "reusedFromOperator": bool( + role == "responsible" and state.get("sameOperator") is True + ), + } + return { + "revision": state["revision"], + "materialName": state["materialName"], + "purpose": "他用" if state["purpose"] == 2 else "自用", + "powerOfAttorney": "已提供" if "powerOfAttorney" in sections else "无需提供", + "authorization": { + "authorizer": business["businessCertificateName"], + "authorizee": state["authorizee"], + "otherMaterialCount": len(sections.get("otherMaterials") or []), + } + if state["purpose"] == 2 + else None, + "business": { + key: business[key] + for key in ( + "businessCertificateName", + "unifiedSocialCreditIdentifier", + "legalPersonName", + "businessCertificateValidityPeriodStart", + "businessCertificateValidityPeriodEnd", + ) + }, + "people": people, + "checks": { + "business": "通过" + if state["checks"]["business"]["matched"] + else "未通过自动校验,将转人工审核", + "legal": "通过" + if state.get("checks", {}).get("legal", {}).get("matched") + else "无需校验", + "operator": "通过", + "responsible": "同经办人,复用经办人校验" + if state.get("sameOperator") is True + else "通过", + }, + } diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/qualification_upload.py b/server/mcp_server_sms/src/mcp_server_sms/core/qualification_upload.py new file mode 100644 index 00000000..add9776a --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/qualification_upload.py @@ -0,0 +1,1332 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""Upload one qualification image with platform STS and run SMS OCR. + +This module is intentionally stdlib-only. Temporary ImageX credentials stay in +memory and never cross the command output boundary. +""" + +from __future__ import annotations + +import datetime +import hashlib +import hmac +import json +import re +import sys +import zlib +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple +from urllib import error, parse, request + +from .api_client import ACTION_REGISTRY, SmsApiClient + + +IMAGEX_ENDPOINT = "https://imagex.bytedanceapi.com" +IMAGEX_REGION = "cn-north-1" +IMAGEX_SERVICE = "ImageX" +IMAGEX_VERSION = "2018-08-01" +IMAGEX_SERVICE_ID = "2rcdq6eupd" +IMAGEX_APP_ID = "5997" +IMAGEX_USER_ID = "sms" +MAX_IMAGE_BYTES = 2 * 1024 * 1024 +ALLOWED_DOCUMENT_TYPES = frozenset({1, 4, 6, 7}) +BUSINESS_CHECK_SKIP_TICKET = "force_skip_check" +MOBILE_VERIFY_APP_ID = 482875 +MOBILE_VERIFY_SCENE = 3 +MOBILE_VERIFY_CHANNEL_ID = 2430 +MOBILE_VERIFY_TEMPLATE_IDS = { + "operator": 59030, + "responsible": 59029, +} + +_UPLOAD_TOKEN_KEYS = ( + "accessKeyId", + "secretAccessKey", + "sessionToken", +) +_OCR_FIELDS = ( + "businessCertificateType", + "businessCertificateName", + "unifiedSocialCreditIdentifier", + "businessCertificateValidityPeriodStart", + "businessCertificateValidityPeriodEnd", + "legalPersonName", +) +_PERSON_OCR_FIELDS = ( + "personName", + "personIDCard", + "isIDCardValid", +) + +_NAME_FIELDS = frozenset( + { + "personname", + "legalpersonname", + "operatorpersonname", + "responsiblepersonname", + "operatorname", + "responsiblename", + } +) +_ID_FIELDS = frozenset({"personidcard", "idcard", "idcardnumber", "identitynumber"}) +_MOBILE_FIELDS = frozenset( + { + "personmobile", + "mobile", + "phone", + "phonenumber", + "operatormobile", + "responsiblemobile", + "legalmobile", + } +) +_FREE_TEXT_FIELDS = frozenset( + {"message", "errormessage", "detail", "reason", "description"} +) +_SECRET_FIELDS = frozenset( + { + "accesskey", + "accesskeyid", + "secretkey", + "secretaccesskey", + "sessiontoken", + "securitytoken", + "authorization", + "auth", + "cookie", + "setcookie", + "token", + "ticket", + "businesscheckticket", + "operatorcheckticket", + "responsiblecheckticket", + "legalcheckticket", + "sessionkey", + "decryptkeys", + } +) +_IMAGE_FIELDS = frozenset( + { + "image", + "imageuri", + "imageuris", + "uri", + "filecontent", + "fileurl", + "fileuri", + "filename", + "imageurl", + "imageurls", + "url", + "signedurl", + "uploadurl", + "objectkey", + "storeuri", + "successoids", + "certificate", + "idcardfrontimage", + "idcardbackimage", + "uploadhosts", + "uploadheader", + } +) +_MOBILE_PATTERN = re.compile(r"(?]+", re.IGNORECASE) + + +class QualificationUploadError(RuntimeError): + def __init__( + self, + code: str, + message: str, + *, + request_id: str = "", + log_id: str = "", + outcome_unknown: bool = False, + ) -> None: + super().__init__(message) + self.code = code + self.request_id = request_id or None + self.log_id = log_id or None + self.outcome_unknown = outcome_unknown + + +class QualificationResponse(dict): + """A decoded response body with its public transport diagnostics.""" + + def __init__( + self, + value: Mapping[str, Any], + *, + http_status: Optional[int], + request_id: str, + log_id: str, + ) -> None: + super().__init__(value) + self.http_status = http_status + self.request_id = request_id + self.log_id = log_id + + +def _diagnostic_key(value: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(value).lower()) + + +def _mask_name(value: str) -> str: + return "*" if len(value) <= 1 else value[0] + "*" * (len(value) - 1) + + +def _mask_id_card(value: str) -> str: + if len(value) < 10: + return "*" * len(value) + return value[:6] + "*" * (len(value) - 10) + value[-4:] + + +def _mask_mobile(value: str) -> str: + if len(value) != 11: + return "*" * len(value) + return value[:3] + "****" + value[-4:] + + +def _protected_summary(value: Any, kind: str) -> Dict[str, Any]: + summary: Dict[str, Any] = {"protected": kind, "present": bool(value)} + if isinstance(value, (list, tuple, Mapping)): + summary["count"] = len(value) + return summary + + +def _add_protected_replacements( + value: Any, replacement: str, result: Dict[str, str] +) -> None: + if isinstance(value, str): + if value: + result[value] = replacement + return + if isinstance(value, Mapping): + for item in value.values(): + _add_protected_replacements(item, replacement, result) + return + if isinstance(value, (list, tuple)): + for item in value: + _add_protected_replacements(item, replacement, result) + + +def _diagnostic_replacements(value: Any) -> Dict[str, str]: + replacements: Dict[str, str] = {} + if not isinstance(value, Mapping): + return replacements + for key, item in value.items(): + field = _diagnostic_key(key) + if field in _SECRET_FIELDS: + _add_protected_replacements(item, "[凭证已隐藏]", replacements) + elif field in _IMAGE_FIELDS: + _add_protected_replacements(item, "[图片已隐藏]", replacements) + elif isinstance(item, str) and item: + if field in _NAME_FIELDS: + replacements[item] = _mask_name(item) + elif field in _ID_FIELDS: + replacements[item] = _mask_id_card(item) + elif field in _MOBILE_FIELDS: + replacements[item] = _mask_mobile(item) + elif isinstance(item, Mapping): + replacements.update(_diagnostic_replacements(item)) + elif isinstance(item, (list, tuple)): + for nested in item: + replacements.update(_diagnostic_replacements(nested)) + return replacements + + +def _mask_diagnostic_text(value: str, replacements: Mapping[str, str]) -> str: + masked = _ID_PATTERN.sub(lambda match: _mask_id_card(match.group(0)), value) + masked = _MOBILE_PATTERN.sub(lambda match: _mask_mobile(match.group(0)), masked) + for raw, replacement in sorted( + replacements.items(), key=lambda item: len(item[0]), reverse=True + ): + masked = masked.replace(raw, replacement) + return _URL_PATTERN.sub("[地址已隐藏]", masked) + + +def _diagnostic_value( + value: Any, + *, + field: str = "", + replacements: Optional[Mapping[str, str]] = None, +) -> Any: + replacements = replacements or {} + normalized = _diagnostic_key(field) + if normalized in _SECRET_FIELDS: + return _protected_summary(value, "credential") + if normalized in _IMAGE_FIELDS: + return _protected_summary(value, "image") + if isinstance(value, Mapping): + return { + str(key): _diagnostic_value( + item, + field=str(key), + replacements=replacements, + ) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_diagnostic_value(item, replacements=replacements) for item in value] + if isinstance(value, str): + if normalized in _NAME_FIELDS: + return _mask_name(value) + if normalized in _ID_FIELDS: + return _mask_id_card(value) + if normalized in _MOBILE_FIELDS: + return _mask_mobile(value) + if normalized in _FREE_TEXT_FIELDS: + return _mask_diagnostic_text(value, replacements) + return value + return value + + +def _qualification_exchange_event( + stage: str, + method: str, + request_value: Mapping[str, Any], + response_value: Mapping[str, Any], + *, + context: Optional[Mapping[str, Any]] = None, + http_status: Optional[int] = None, + request_id: str = "", + log_id: str = "", +) -> Dict[str, Any]: + replacements = _diagnostic_replacements(request_value) + replacements.update(_diagnostic_replacements(response_value)) + return { + "event": "qualification_exchange", + "stage": stage, + "method": method.upper(), + "context": _diagnostic_value(context or {}, replacements=replacements), + "request": _diagnostic_value(request_value, replacements=replacements), + "response": _diagnostic_value(response_value, replacements=replacements), + "httpStatus": http_status, + "requestId": request_id or None, + "logId": log_id or None, + } + + +def _emit_qualification_exchange( + stage: str, + method: str, + request_value: Mapping[str, Any], + response_value: Mapping[str, Any], + *, + context: Optional[Mapping[str, Any]] = None, + http_status: Optional[int] = None, + request_id: str = "", + log_id: str = "", +) -> None: + event = _qualification_exchange_event( + stage, + method, + request_value, + response_value, + context=context, + http_status=http_status, + request_id=request_id, + log_id=log_id, + ) + sys.stderr.write( + "QUALIFICATION_EXCHANGE " + + json.dumps(event, ensure_ascii=False, separators=(",", ":")) + + "\n" + ) + sys.stderr.flush() + + +@dataclass(frozen=True) +class ImageXCredentials: + access_key: str + secret_key: str + session_token: str + + +def _response_request_id(payload: Mapping[str, Any], headers: Any = None) -> str: + transport_request_id = getattr(payload, "request_id", "") + if transport_request_id: + return str(transport_request_id) + metadata = payload.get("ResponseMetadata") + if isinstance(metadata, Mapping): + request_id = metadata.get("RequestId") or metadata.get("RequestID") + if request_id: + return str(request_id) + request_id = payload.get("RequestId") or payload.get("RequestID") + if request_id: + return str(request_id) + return _header_value(headers, "X-Tt-RequestId", "X-Request-Id", "X-RequestId") + + +def _response_result(payload: Mapping[str, Any], stage: str) -> Mapping[str, Any]: + metadata = payload.get("ResponseMetadata") + if isinstance(metadata, Mapping): + failure = metadata.get("Error") + if isinstance(failure, Mapping): + code = str(failure.get("Code") or failure.get("CodeN") or "api_error") + message = str( + failure.get("Message") + or failure.get("message") + or "{} failed.".format(stage) + ) + raise QualificationUploadError( + code, + message, + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + http_status = getattr(payload, "http_status", None) + if http_status is not None and not 200 <= http_status < 300: + raise QualificationUploadError( + "http_{}".format(http_status), + "{} returned HTTP {}.".format(stage, http_status), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + result = payload.get("Result") + if not isinstance(result, Mapping): + raise QualificationUploadError( + "invalid_response", + "{} returned no structured result.".format(stage), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + return result + + +def _response_value(payload: Mapping[str, Any], stage: str) -> Any: + """Return a successful Result value, including scalar write results.""" + metadata = payload.get("ResponseMetadata") + if isinstance(metadata, Mapping): + failure = metadata.get("Error") + if isinstance(failure, Mapping): + code = str(failure.get("Code") or failure.get("CodeN") or "api_error") + raise QualificationUploadError( + code, + "{} failed.".format(stage), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + http_status = getattr(payload, "http_status", None) + if http_status is not None and not 200 <= http_status < 300: + raise QualificationUploadError( + "http_{}".format(http_status), + "{} returned HTTP {}.".format(stage, http_status), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + if "Result" not in payload: + raise QualificationUploadError( + "invalid_response", + "{} returned no structured result.".format(stage), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + return payload["Result"] + + +def _header_value(headers: Any, *names: str) -> str: + if headers is None: + return "" + for name in names: + try: + value = headers.get(name) + except AttributeError: + value = None + if value: + return str(value) + return "" + + +def _response_log_id(payload: Mapping[str, Any], headers: Any) -> str: + transport_log_id = getattr(payload, "log_id", "") + if transport_log_id: + return str(transport_log_id) + header_value = _header_value(headers, "X-Tt-Logid", "X-Tt-Log-Id") + if header_value: + return header_value + metadata = payload.get("ResponseMetadata") + if isinstance(metadata, Mapping): + return str(metadata.get("LogId") or metadata.get("LogID") or "") + return str(payload.get("LogId") or payload.get("LogID") or "") + + +def _open_json( + req: request.Request, + timeout: float, + stage: str, + *, + diagnostic_request: Optional[Mapping[str, Any]] = None, + diagnostic_context: Optional[Mapping[str, Any]] = None, +) -> Mapping[str, Any]: + request_value = dict(diagnostic_request or {}) + method = req.get_method() + status_code: Optional[int] = None + headers: Any = None + try: + with request.urlopen(req, timeout=timeout) as response: + status_code = response.getcode() + headers = response.headers + data = response.read() + except error.HTTPError as exc: + status_code = exc.code + headers = exc.headers + data = exc.read() if hasattr(exc, "read") else b"" + except (OSError, error.URLError, TimeoutError) as exc: + _emit_qualification_exchange( + stage, + method, + request_value, + { + "error": { + "code": "network_error", + "message": "Request could not reach the service.", + } + }, + context=diagnostic_context, + ) + raise QualificationUploadError( + "network_error", "{} could not reach the service.".format(stage) + ) from exc + try: + value = json.loads(data.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as exc: + _emit_qualification_exchange( + stage, + method, + request_value, + { + "error": { + "code": "invalid_response", + "message": "Response was not valid JSON.", + }, + "bodySize": len(data), + }, + context=diagnostic_context, + http_status=status_code, + request_id=_response_request_id({}, headers), + log_id=_header_value(headers, "X-Tt-Logid", "X-Tt-Log-Id"), + ) + raise QualificationUploadError( + "invalid_response", + "{} returned an invalid response.".format(stage), + request_id=_response_request_id({}, headers), + log_id=_header_value(headers, "X-Tt-Logid", "X-Tt-Log-Id"), + ) from exc + if not isinstance(value, Mapping): + _emit_qualification_exchange( + stage, + method, + request_value, + { + "error": { + "code": "invalid_response", + "message": "Response root was not an object.", + } + }, + context=diagnostic_context, + http_status=status_code, + request_id=_response_request_id({}, headers), + log_id=_header_value(headers, "X-Tt-Logid", "X-Tt-Log-Id"), + ) + raise QualificationUploadError( + "invalid_response", + "{} returned an invalid response.".format(stage), + request_id=_response_request_id({}, headers), + log_id=_header_value(headers, "X-Tt-Logid", "X-Tt-Log-Id"), + ) + response_request_id = _response_request_id(value, headers) + response_log_id = _response_log_id(value, headers) + _emit_qualification_exchange( + stage, + method, + request_value, + value, + context=diagnostic_context, + http_status=status_code, + request_id=response_request_id, + log_id=response_log_id, + ) + return QualificationResponse( + value, + http_status=status_code, + request_id=response_request_id, + log_id=response_log_id, + ) + + +def _sms_agent_call( + client: SmsApiClient, + action: str, + params: Mapping[str, Any], + *, + diagnostic_context: Optional[Mapping[str, Any]] = None, +) -> QualificationResponse: + """Call one published Agent Action and preserve qualification diagnostics.""" + spec = ACTION_REGISTRY.get(action) + if spec is None: + raise QualificationUploadError( + "unknown_action", + "{} is not in the published Action contract.".format(action), + ) + method = spec.method + request_value = dict(params) + payload = client.call(action, request_value) + diagnostic_request = dict(request_value) + if action == "CheckSmsVerifyCodeByMobile" and "code" in diagnostic_request: + diagnostic_request["code"] = _protected_summary( + diagnostic_request["code"], "verification_code" + ) + if not isinstance(payload, Mapping): + _emit_qualification_exchange( + action, + method, + diagnostic_request, + {"error": {"code": "invalid_response"}}, + context=diagnostic_context, + ) + raise QualificationUploadError( + "invalid_response", "{} returned an invalid response.".format(action) + ) + + request_id = str(payload.get("request_id") or "") + log_id = str(payload.get("log_id") or "") + _emit_qualification_exchange( + action, + method, + diagnostic_request, + payload, + context=diagnostic_context, + request_id=request_id, + log_id=log_id, + ) + if payload.get("success") is not True: + failure = payload.get("error") + if not isinstance(failure, Mapping): + raise QualificationUploadError( + "invalid_response", + "{} returned an invalid error response.".format(action), + request_id=request_id, + log_id=log_id, + ) + code = str(failure.get("code") or "api_error") + message = str(failure.get("message") or "{} failed.".format(action)) + raise QualificationUploadError( + code, + message, + request_id=request_id, + log_id=log_id, + outcome_unknown=bool(failure.get("outcome_unknown")), + ) + if "result" not in payload: + raise QualificationUploadError( + "invalid_response", + "{} returned no result.".format(action), + request_id=request_id, + log_id=log_id, + ) + return QualificationResponse( + { + "ResponseMetadata": {"RequestId": request_id} if request_id else {}, + "Result": payload["result"], + }, + http_status=200, + request_id=request_id, + log_id=log_id, + ) + + +def send_mobile_verification_code( + client: SmsApiClient, + mobile: str, + role: str, +) -> str: + """Send one qualification mobile verification code.""" + if role not in ("operator", "responsible"): + raise QualificationUploadError("invalid_role", "手机号验证码角色无效。") + action = "SendSmsVerifyCodeByMobile" + payload = _sms_agent_call( + client, + action, + { + "appId": MOBILE_VERIFY_APP_ID, + "scene": MOBILE_VERIFY_SCENE, + "codeType": 0, + "mobile": mobile, + "templateId": MOBILE_VERIFY_TEMPLATE_IDS[role], + "channelId": MOBILE_VERIFY_CHANNEL_ID, + }, + diagnostic_context={"phase": "mobile_code_send", "role": role}, + ) + result = _response_result(payload, action) + message_id = result.get("messageId") + if not isinstance(message_id, str) or not message_id.strip(): + raise QualificationUploadError( + "invalid_response", + "验证码发送接口未返回消息 ID。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + return message_id.strip() + + +def check_mobile_verification_code( + client: SmsApiClient, + mobile: str, + code: str, + role: str, +) -> None: + """Check one qualification mobile verification code.""" + if role not in ("operator", "responsible"): + raise QualificationUploadError("invalid_role", "手机号验证码角色无效。") + action = "CheckSmsVerifyCodeByMobile" + payload = _sms_agent_call( + client, + action, + { + "appId": MOBILE_VERIFY_APP_ID, + "scene": MOBILE_VERIFY_SCENE, + "mobile": mobile, + "code": code, + "func": 2, + }, + diagnostic_context={"phase": "mobile_code_check", "role": role}, + ) + result = _response_result(payload, action) + status = result.get("status") + if isinstance(status, bool) or not isinstance(status, int): + raise QualificationUploadError( + "invalid_response", + "验证码校验接口返回了无效状态。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + if status == 0: + return + if status == 1: + raise QualificationUploadError( + "mobile_code_invalid", "验证码不正确,请重新输入。" + ) + if status == 2: + raise QualificationUploadError( + "mobile_code_expired", "验证码已过期,请重新获取。" + ) + raise QualificationUploadError( + "invalid_response", + "验证码校验接口返回了未知状态。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + + +def get_account_ident_rank(client: SmsApiClient) -> Dict[str, bool]: + """Load the server-owned qualification requirements for the current account.""" + action = "GetAccountIdentRankForAgent" + payload = _sms_agent_call( + client, + action, + {}, + diagnostic_context={"phase": "requirements"}, + ) + result = _response_result(payload, action) + fields = ( + "isOnlyTwoElement", + "isSkipOtherUse", + "needOtherUseCheck", + "operatorThreeElement", + "needOperatorImage", + "needOperatorMobile", + "responsibleThreeElement", + "needResponsibleImage", + "needResponsibleMobile", + "needBusinessCertificateImage", + ) + if any( + field not in result or not isinstance(result[field], bool) for field in fields + ): + raise QualificationUploadError( + "invalid_response", + "{} returned incomplete requirements.".format(action), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + return {field: result[field] for field in fields} + + +def get_account_identity(client: SmsApiClient) -> Dict[str, str]: + """Return qualification-relevant identity fields for the current account.""" + action = "ListAllSmsProduct" + payload = _sms_agent_call( + client, + action, + {}, + diagnostic_context={"phase": "account_identity"}, + ) + result = _response_result(payload, action) + business_name = result.get("businessName") + user_type = result.get("userType") + if not isinstance(business_name, str) or not business_name.strip(): + raise QualificationUploadError( + "invalid_response", + "当前火山账号未返回企业名称,暂时无法创建资质。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + if not isinstance(user_type, str) or not user_type.strip(): + raise QualificationUploadError( + "invalid_response", + "当前火山账号未返回用户类型,暂时无法创建资质。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + return { + "businessName": business_name.strip(), + "userType": user_type.strip().lower(), + } + + +def get_account_business_name(client: SmsApiClient) -> str: + """Return the enterprise name bound to the current Volcengine account.""" + return get_account_identity(client)["businessName"] + + +def normalize_check_status(value: Any) -> str: + """Normalize documented check codes and the service's rune-encoded form.""" + if isinstance(value, bool): + return "" + if isinstance(value, int): + return str(value) if 0 <= value <= 3 else "" + if not isinstance(value, str): + return "" + if value in ("0", "1", "2", "3"): + return value + if len(value) == 1 and 0 <= ord(value) <= 3: + return str(ord(value)) + return "" + + +def check_signature_qualification_information( + client: SmsApiClient, + target: str, + params: Mapping[str, Any], +) -> Dict[str, Any]: + """Run one customer-requested qualification check without exposing its ticket.""" + if target not in ("business", "legal", "operator", "responsible"): + raise QualificationUploadError( + "qualification_check_invalid", "不支持当前信息校验。" + ) + action = ( + "ThreeElementEnterpriseCheckForAgent" + if target == "business" + else "ThreeElementPersonCheckForAgent" + ) + payload = _sms_agent_call( + client, + action, + params, + diagnostic_context={"phase": "verification", "role": target}, + ) + result = _response_result(payload, action) + status = normalize_check_status(result.get("status")) + ticket = str(result.get("ticket") or "") + if status not in ("0", "1", "2", "3"): + raise QualificationUploadError( + "invalid_response", + "校验服务没有返回可识别的校验状态,请稍后重试。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + matched = status == "0" and bool(ticket) + if status == "0" and not ticket: + raise QualificationUploadError( + "invalid_response", + "校验服务没有返回有效结果,请稍后重试。", + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + ) + if not matched: + ticket = BUSINESS_CHECK_SKIP_TICKET if target == "business" else "" + return { + "target": target, + "status": status, + "matched": matched, + "canContinue": target == "business" or matched, + "ticket": ticket, + } + + +def create_signature_qualification( + client: SmsApiClient, + params: Mapping[str, Any], +) -> Dict[str, Any]: + """Create one review order with tickets obtained from explicit information checks. + + The caller must preserve the Agent Action's structured outcome flag and must + never retry an uncertain write automatically. + """ + body = dict(params) + business = body.get("businessInfo") + operator = body.get("operatorPerson") + responsible = body.get("responsiblePersonInfo") + if not all( + isinstance(value, Mapping) for value in (business, operator, responsible) + ): + raise QualificationUploadError( + "qualification_draft_invalid", "The qualification draft is incomplete." + ) + required_tickets = ( + "businessCheckTicket", + "operatorCheckTicket", + "responsibleCheckTicket", + ) + if any(not str(body.get(name) or "") for name in required_tickets): + raise QualificationUploadError( + "qualification_check_required", "请先完成资质信息校验。" + ) + legal = body.get("legalPerson") + if ( + isinstance(legal, Mapping) + and int(legal.get("certificateType") or 0) == 0 + and legal.get("personIDCard") + and not str(body.get("legalCheckTicket") or "") + ): + raise QualificationUploadError( + "qualification_check_required", "请先完成法人信息校验。" + ) + if ( + body.get("sameOperator") is True + and body["responsibleCheckTicket"] != body["operatorCheckTicket"] + ): + raise QualificationUploadError( + "qualification_check_invalid", "经办人校验结果已变化,请重新校验。" + ) + if int(body.get("purpose") or 0) == 2 and ( + not str(body.get("authorizer") or "").strip() + or not str(body.get("authorizee") or "").strip() + ): + raise QualificationUploadError( + "qualification_draft_invalid", "请完善授权方和被授权方信息。" + ) + + action = "ApplySignatureIdentificationForAgent" + try: + payload = _sms_agent_call( + client, + action, + body, + diagnostic_context={"phase": "submission"}, + ) + result = _response_value(payload, action) + qualification_id = int(result) + except (TypeError, ValueError) as exc: + raise QualificationUploadError( + "invalid_response", + "{} returned no qualification id.".format(action), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + outcome_unknown=True, + ) from exc + if qualification_id <= 0: + raise QualificationUploadError( + "invalid_response", + "{} returned no qualification id.".format(action), + request_id=_response_request_id(payload), + log_id=_response_log_id(payload, None), + outcome_unknown=True, + ) + return { + "qualificationId": qualification_id, + "requestId": _response_request_id(payload) or None, + "logId": _response_log_id(payload, None) or None, + "status": "submitted_for_review", + } + + +def _aws_hmac(key: bytes, value: str) -> bytes: + return hmac.new(key, value.encode("utf-8"), hashlib.sha256).digest() + + +def _aws_sign( + method: str, + query: Sequence[Tuple[str, str]], + body: bytes, + credentials: ImageXCredentials, + headers: Optional[Mapping[str, str]] = None, +) -> request.Request: + endpoint = parse.urlsplit(IMAGEX_ENDPOINT) + canonical_query = "&".join( + "{}={}".format( + parse.quote(str(key), safe="-_.~"), + parse.quote(str(value), safe="-_.~"), + ) + for key, value in sorted(query) + ) + now = datetime.datetime.now(datetime.timezone.utc) + x_date = now.strftime("%Y%m%dT%H%M%SZ") + short_date = x_date[:8] + content_type = ( + "application/json" + if method.upper() == "POST" + else "application/x-www-form-urlencoded; charset=utf-8" + ) + body_hash = hashlib.sha256(body).hexdigest() + signed_values = { + "content-type": content_type, + "host": endpoint.netloc, + "x-amz-content-sha256": body_hash, + "x-amz-date": x_date, + "x-amz-security-token": credentials.session_token, + } + signed_names = ";".join(sorted(signed_values)) + canonical_headers = "".join( + "{}:{}\n".format(name, signed_values[name].strip()) + for name in sorted(signed_values) + ) + canonical_request = "\n".join( + [ + method.upper(), + "/", + canonical_query, + canonical_headers, + signed_names, + body_hash, + ] + ) + scope = "{}/{}/{}/aws4_request".format(short_date, IMAGEX_REGION, IMAGEX_SERVICE) + string_to_sign = "\n".join( + [ + "AWS4-HMAC-SHA256", + x_date, + scope, + hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), + ] + ) + date_key = _aws_hmac(("AWS4" + credentials.secret_key).encode("utf-8"), short_date) + region_key = _aws_hmac(date_key, IMAGEX_REGION) + service_key = _aws_hmac(region_key, IMAGEX_SERVICE) + signing_key = _aws_hmac(service_key, "aws4_request") + signature = hmac.new( + signing_key, string_to_sign.encode("utf-8"), hashlib.sha256 + ).hexdigest() + request_headers: Dict[str, str] = { + "Host": endpoint.netloc, + "Content-Type": content_type, + "X-Amz-Content-Sha256": body_hash, + "X-Amz-Date": x_date, + "X-Amz-Security-Token": credentials.session_token, + "Authorization": ( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}" + ).format(credentials.access_key, scope, signed_names, signature), + } + request_headers.update(dict(headers or {})) + url = "{}?{}".format(IMAGEX_ENDPOINT.rstrip("/") + "/", canonical_query) + return request.Request( + url, + data=body if method.upper() != "GET" else None, + headers=request_headers, + method=method.upper(), + ) + + +def _image_credentials(upload_result: Mapping[str, Any]) -> ImageXCredentials: + token = upload_result.get("token") + if not isinstance(token, Mapping) or not all( + token.get(key) for key in _UPLOAD_TOKEN_KEYS + ): + raise QualificationUploadError( + "upload_credentials_unavailable", + "The SMS service returned no usable material-upload credentials.", + ) + return ImageXCredentials( + str(token["accessKeyId"]), + str(token["secretAccessKey"]), + str(token["sessionToken"]), + ) + + +def _load_image_credentials(client: SmsApiClient) -> ImageXCredentials: + action = "GetMUploadParam" + payload = _sms_agent_call( + client, + action, + {}, + diagnostic_context={"phase": "material_upload"}, + ) + return _image_credentials(_response_result(payload, action)) + + +def _validated_image_bytes(data: bytes, content_type: str) -> Tuple[bytes, str]: + if not data or len(data) > MAX_IMAGE_BYTES: + raise QualificationUploadError( + "invalid_image_size", + "Use a non-empty JPG, JPEG, or PNG no larger than 2 MB.", + ) + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return data, "image/png" + if data.startswith(b"\xff\xd8\xff"): + return data, "image/jpeg" + raise QualificationUploadError( + "invalid_image_type", + "Use a JPEG or PNG image, not {}.".format(content_type or "this file type"), + ) + + +def _apply_image_upload( + credentials: ImageXCredentials, *, timeout: float +) -> Mapping[str, Any]: + query = ( + ("Action", "ApplyImageUpload"), + ("Version", IMAGEX_VERSION), + ("ServiceId", IMAGEX_SERVICE_ID), + ("NeedFallback", "true"), + ("UploadNum", "1"), + ("uid", IMAGEX_USER_ID), + ("appid", IMAGEX_APP_ID), + ) + req = _aws_sign( + "GET", + query, + b"", + credentials, + { + "X-Vod-Upload-AppID": IMAGEX_APP_ID, + "X-Vod-Upload-UserID": IMAGEX_USER_ID, + }, + ) + payload = _open_json( + req, + timeout, + "ApplyImageUpload", + diagnostic_request=dict(query), + diagnostic_context={"phase": "material_upload"}, + ) + return _response_result(payload, "ApplyImageUpload") + + +def _upload_image_bytes( + upload_address: Mapping[str, Any], + data: bytes, + content_type: str, + *, + timeout: float, +) -> Tuple[str, str]: + hosts = upload_address.get("UploadHosts") + stores = upload_address.get("StoreInfos") + if ( + not isinstance(hosts, list) + or not hosts + or not isinstance(stores, list) + or len(stores) != 1 + ): + raise QualificationUploadError( + "invalid_upload_address", "ImageX returned an invalid upload address." + ) + host = str(hosts[0]).strip() + parsed_host = parse.urlsplit("https://" + host) + if ( + not host + or parsed_host.hostname != host + or parsed_host.port is not None + or parsed_host.path not in ("", "/") + ): + raise QualificationUploadError( + "invalid_upload_address", "ImageX returned an invalid upload host." + ) + store = stores[0] + if not isinstance(store, Mapping): + raise QualificationUploadError( + "invalid_upload_address", "ImageX returned invalid upload metadata." + ) + store_uri = str(store.get("StoreUri") or "").strip() + authorization = str(store.get("Auth") or "").strip() + bucket, separator, object_key = store_uri.partition("/") + if not separator or not bucket or not object_key or not authorization: + raise QualificationUploadError( + "invalid_upload_address", "ImageX returned invalid upload metadata." + ) + url = "https://{}/upload/v1/{}/{}".format( + host, + parse.quote(bucket, safe="-_.~"), + parse.quote(object_key, safe="/-_.~"), + ) + headers = { + "Authorization": authorization, + "X-Upload-Content-CRC32": "{:08x}".format(zlib.crc32(data) & 0xFFFFFFFF), + "Specified-Content-Type": content_type, + } + upload_headers = upload_address.get("UploadHeader") + if isinstance(upload_headers, Mapping): + for key, value in upload_headers.items(): + if isinstance(key, str) and isinstance(value, str): + headers[key] = value + payload = _open_json( + request.Request(url, data=data, headers=headers, method="POST"), + timeout, + "ImageX upload", + diagnostic_request={ + "contentType": content_type, + "imageSizeBytes": len(data), + }, + diagnostic_context={"phase": "material_upload"}, + ) + if int(payload.get("code") or 0) != 2000: + raise QualificationUploadError("image_upload_failed", "Image upload failed.") + return store_uri, str(upload_address.get("SessionKey") or "") + + +def _commit_image_upload( + credentials: ImageXCredentials, + session_key: str, + store_uri: str, + *, + timeout: float, +) -> str: + body_document = { + "SessionKey": session_key, + "SuccessOids": [store_uri], + "Functions": [], + "PostProcess": [], + "DecryptKeys": [], + } + body = json.dumps( + body_document, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + query = ( + ("Action", "CommitImageUpload"), + ("Version", IMAGEX_VERSION), + ("ServiceId", IMAGEX_SERVICE_ID), + ("SkipMeta", "true"), + ) + req = _aws_sign( + "POST", + query, + body, + credentials, + { + "X-Vod-Upload-AppID": IMAGEX_APP_ID, + "X-Vod-Upload-UserID": IMAGEX_USER_ID, + }, + ) + payload = _open_json( + req, + timeout, + "CommitImageUpload", + diagnostic_request=body_document, + diagnostic_context={"phase": "material_upload"}, + ) + result = _response_result(payload, "CommitImageUpload") + results = result.get("Results") + if ( + not isinstance(results, list) + or not results + or not isinstance(results[0], Mapping) + ): + raise QualificationUploadError( + "invalid_response", "ImageX returned no committed image." + ) + image = results[0] + image_uri = str(image.get("Uri") or "").strip() + if int(image.get("UriStatus") or 0) != 2000 or not image_uri: + raise QualificationUploadError( + "image_commit_failed", "Image upload commit failed." + ) + return image_uri + + +def _upload_one_image( + credentials: ImageXCredentials, + data: bytes, + content_type: str, + *, + timeout: float, +) -> str: + apply_result = _apply_image_upload(credentials, timeout=timeout) + upload_address = apply_result.get("UploadAddress") + if not isinstance(upload_address, Mapping): + raise QualificationUploadError( + "invalid_upload_address", "ImageX returned no upload address." + ) + store_uri, session_key = _upload_image_bytes( + upload_address, data, content_type, timeout=timeout + ) + return _commit_image_upload(credentials, session_key, store_uri, timeout=timeout) + + +def upload_qualification_file_bytes( + client: SmsApiClient, + data: bytes, + content_type: str, +) -> Dict[str, str]: + """Upload one image material and return only its internal URI metadata.""" + data, content_type = _validated_image_bytes(data, content_type) + timeout = max(1.0, float(client._timeout)) + image_credentials = _load_image_credentials(client) + try: + image_uri = _upload_one_image( + image_credentials, data, content_type, timeout=timeout + ) + finally: + image_credentials = None + return { + "imageUri": image_uri, + "imageSuffix": "png" if content_type == "image/png" else "jpg", + } + + +def ocr_business_certificate_image(client, image_uri, certificate_type): + """Recognize an already uploaded image without uploading it a second time.""" + if certificate_type not in ALLOWED_DOCUMENT_TYPES: + raise QualificationUploadError( + "invalid_document_type", "当前营业证件类型不支持 OCR" + ) + payload = _sms_agent_call( + client, + "GetOCRLicenseForAgent", + {"businessCertificateType": certificate_type, "certificate": image_uri}, + diagnostic_context={"phase": "ocr", "document": "business"}, + ) + result = _response_result(payload, "GetOCRLicenseForAgent") + return {key: result[key] for key in _OCR_FIELDS if key in result} + + +def ocr_identity_document_images( + client: SmsApiClient, + front_uri: str, + back_uri: str, + *, + image_suffixes: Optional[Mapping[str, str]] = None, +) -> Dict[str, Any]: + image_suffixes = dict(image_suffixes or {"front": "jpg", "back": "jpg"}) + ocr_action = "GetOCRLicenseForAgent" + ocr_payload = _sms_agent_call( + client, + ocr_action, + { + "iDCardFrontImage": front_uri, + "iDCardBackImage": back_uri, + }, + diagnostic_context={"phase": "ocr", "document": "identity"}, + ) + ocr_result = _response_result(ocr_payload, ocr_action) + return { + "status": "ocr_completed", + "imageUris": { + "front": front_uri, + "back": back_uri, + }, + "imageSuffixes": image_suffixes, + "person": { + key: ocr_result.get(key) for key in _PERSON_OCR_FIELDS if key in ocr_result + }, + "next": "confirm_person_fields", + } diff --git a/server/mcp_server_sms/src/mcp_server_sms/core/workflows.py b/server/mcp_server_sms/src/mcp_server_sms/core/workflows.py new file mode 100644 index 00000000..a4d0d25a --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/core/workflows.py @@ -0,0 +1,2143 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Licensed under the Apache License, Version 2.0. +# Adapted from byted-sms-sender at c7c49836dff727ca7a2ab297dc9d1ad62c5aec15. + +from __future__ import annotations +import argparse +import calendar +import csv +import datetime +import hashlib +import hmac +import io +import json +import math +import pathlib +import re +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple +from urllib import parse, request +from .analytics import AnalyticsError, build_report +from .api_client import SmsApiClient + + +class CliError(ValueError): + def __init__( + self, + message: str, + code: str = "validation_error", + *, + request_id: Optional[str] = None, + log_id: Optional[str] = None, + retryable: bool = False, + outcome_unknown: bool = False, + remediation: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__(message) + self.code = code + self.request_id = request_id + self.log_id = log_id + self.retryable = retryable + self.outcome_unknown = outcome_unknown + self.remediation = dict(remediation) if remediation else None + + +class JsonArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise CliError(message, "argument_error") + + +def _local_success(action: str, result: Mapping[str, Any]) -> Dict[str, Any]: + return { + "success": True, + "action": action, + "request_id": result.get("requestId"), + "log_id": result.get("logId"), + "result": dict(result), + "error": None, + } + + +def canonical_digest(payload: Mapping[str, Any]) -> str: + encoded = json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _normalize_signature(value: str) -> str: + text = value.strip() + bracket_pairs = (("【", "】"), ("[", "]"), ("[", "]")) + for left, right in bracket_pairs: + if text.startswith(left) and text.endswith(right): + text = text[len(left) : -len(right)].strip() + break + if not 2 <= len(text) <= 25: + raise CliError("signature content must contain 2 to 25 characters") + return text + + +def _items(envelope: Mapping[str, Any]) -> List[Mapping[str, Any]]: + if not envelope.get("success"): + return [] + result = envelope.get("result") + if not isinstance(result, Mapping): + return [] + values = ( + result.get("List") + or result.get("list") + or result.get("Items") + or result.get("items") + or [] + ) + return [value for value in values if isinstance(value, Mapping)] + + +def _is_approved(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int): + return value in (3, 5) + return str(value).strip().lower() in { + "3", + "5", + "approved", + "passed", + "success", + "completed", + "no_review", + "no-review", + "审核通过", + "免审", + } + + +def _is_template_signature_usable(item: Mapping[str, Any]) -> bool: + value = item.get("usable") + if value is None: + value = item.get("Usable") + return value is None or value is True + + +def _channels(item: Mapping[str, Any]) -> set: + values = item.get("ChannelTypes") + if values is None: + values = item.get("channelTypes") + if values is None: + single = item.get("ChannelType") + if single is None: + single = item.get("channelType") + values = [single] if single else [] + if isinstance(values, str): + values = [part for part in values.split(",") if part] + return {str(value) for value in values} + + +def _require_query_success(envelope: Mapping[str, Any]) -> None: + if not envelope.get("success"): + error_value = envelope.get("error") + message = ( + error_value.get("message") + if isinstance(error_value, Mapping) + else "resource query failed" + ) + code = ( + error_value.get("code") + if isinstance(error_value, Mapping) + else "resource_query_failed" + ) + remediation = ( + error_value.get("remediation") + if isinstance(error_value, Mapping) + and isinstance(error_value.get("remediation"), Mapping) + else None + ) + raise CliError( + str(message), + str(code or "resource_query_failed"), + request_id=str(envelope.get("request_id")) + if envelope.get("request_id") is not None + else None, + retryable=bool(error_value.get("retryable")) + if isinstance(error_value, Mapping) + else False, + outcome_unknown=bool(error_value.get("outcome_unknown")) + if isinstance(error_value, Mapping) + else False, + remediation=remediation, + ) + + +def _validate_signature_resources( + client: SmsApiClient, + qualification_id: int, + purpose: int, + sub_accounts: Sequence[str], + channel_types: Sequence[str], +) -> List[Dict[str, str]]: + qualification = client.call( + "GetSignatureIdentificationList", + {"id": qualification_id, "pageIndex": 1, "pageSize": 100}, + ) + _require_query_success(qualification) + matched = [ + item + for item in _items(qualification) + if str(item.get("id")) == str(qualification_id) + ] + if not matched: + raise CliError("qualification is unavailable; apply for it in the SMS console") + selected = matched[0] + if not selected.get("usable") or not _is_approved(selected.get("auditStatus")): + raise CliError("qualification must be approved and usable") + if selected.get("purpose") is not None and int(selected["purpose"]) != purpose: + raise CliError("qualification purpose does not match the application") + groups = client.call("ListSubAccountForAgent", {}) + _require_query_success(groups) + group_items = _items(groups) + available = { + str(item.get("SubAccount")) + for item in group_items + if item.get("SubAccount") is not None + } + missing = [group for group in sub_accounts if group not in available] + if missing: + raise CliError( + "unknown or unavailable message group: {}".format(", ".join(missing)) + ) + unsupported_by_group = [] + for item in group_items: + group = str(item.get("SubAccount")) + if group not in sub_accounts: + continue + declared_channels = _channels(item) + if not declared_channels: + continue + unsupported = [ + channel for channel in channel_types if channel not in declared_channels + ] + if unsupported: + unsupported_by_group.append("{}: {}".format(group, ", ".join(unsupported))) + if unsupported_by_group: + raise CliError( + "selected message groups do not support channel type: {}".format( + "; ".join(unsupported_by_group) + ) + ) + groups_by_id = { + str(item.get("SubAccount")): item + for item in group_items + if item.get("SubAccount") is not None + } + summary = [] + for group in sub_accounts: + raw_name = groups_by_id[group].get("SubAccountName") + name = str(raw_name) if isinstance(raw_name, (str, int, float, bool)) else "" + summary.append({"subAccount": group, "subAccountName": name}) + return summary + + +def _signature_body(args: argparse.Namespace) -> Dict[str, Any]: + if args.app_icp is not None and args.source != 2: + raise CliError("app-icp is valid only when source is 2 (App)") + if args.trademark is not None and args.source != 3: + raise CliError("trademark is valid only when source is 3 (trademark)") + body: Dict[str, Any] = { + "content": _normalize_signature(args.content), + "purpose": args.purpose, + "source": args.source, + "signatureIdentificationID": args.qualification_id, + "subAccounts": list(dict.fromkeys(args.sub_account)), + "channelTypes": list(dict.fromkeys(args.channel_type)), + } + optional = { + "desc": args.description, + "domain": args.domain, + "scene": args.scene, + "projectName": args.project_name, + "appIcp": _parse_typed_json_object( + args.app_icp, "app-icp", {"appIcpFilling": str} + ), + "trademark": _parse_typed_json_object( + args.trademark, + "trademark", + {"trademarkCn": str, "trademarkEn": str, "trademarkNumber": str}, + ), + } + body.update({key: value for key, value in optional.items() if value is not None}) + return body + + +_VARIABLE_RE = re.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}") +_SPECIAL_VARIABLES = {"url", "link", "short_url", "shortUrl"} +_MOBILE_RE = re.compile("^1[3-9]\\d{9}$") +MAX_DIRECT_RECIPIENTS = 200 +MAX_BATCH_FILE_BYTES = 50 * 1024 * 1024 +MAX_BATCH_ROWS = 1000000 +MAX_TEMPLATE_MATCH_PAGES = 100 +CHINA_TZ = datetime.timezone(datetime.timedelta(hours=8)) +CANCEL_LEAD_TIME = datetime.timedelta(minutes=1) + + +def _parse_typed_json_object( + raw: Optional[str], name: str, field_types: Mapping[str, type] +) -> Optional[Mapping[str, Any]]: + if raw is None: + return None + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise CliError("{} must be a JSON object".format(name)) from exc + if not isinstance(value, Mapping): + raise CliError("{} must be a JSON object".format(name)) + unknown = sorted(set(value).difference(field_types)) + if unknown: + raise CliError( + "{} contains unsupported fields: {}".format(name, ", ".join(unknown)) + ) + for key, item in value.items(): + expected = field_types[key] + if isinstance(item, bool) or not isinstance(item, expected): + raise CliError( + "{}.{} must be {}".format( + name, key, "an integer" if expected is int else "a string" + ) + ) + return dict(value) + + +def _parse_short_url_config(raw: Optional[str]) -> Optional[Mapping[str, Any]]: + return _parse_typed_json_object( + raw, + "short-url-config", + { + "isEnabled": str, + "belong": str, + "isNeedClickDetails": str, + "uaCheckStrategy": int, + }, + ) + + +def _template_body(args: argparse.Namespace) -> Dict[str, Any]: + declared = list(args.template_param) + if len(set(declared)) != len(declared): + raise CliError("template parameters must not contain duplicates") + extracted = _VARIABLE_RE.findall(args.content) + if set(declared) != set(extracted) or len(extracted) != len(set(extracted)): + raise CliError( + "template parameters must exactly match unique ${name} variables in content" + ) + if args.channel_type == "CN_OTP" and ( + not any((name not in _SPECIAL_VARIABLES for name in declared)) + ): + raise CliError("OTP templates require at least one ordinary variable") + short_url = _parse_short_url_config(args.short_url_config) + if short_url is not None and args.channel_type != "CN_MKT": + raise CliError("short-link options are available only to marketing templates") + body: Dict[str, Any] = { + "content": args.content, + "channelType": args.channel_type, + "area": "cn", + "name": args.name, + "signatures": [_normalize_signature(value) for value in args.signature], + "subAccounts": list(dict.fromkeys(args.sub_account)), + "templateParams": [{"name": name} for name in declared], + } + if args.project is not None: + body["project"] = args.project + if args.description is not None: + body["desc"] = args.description + if short_url is not None: + body["shortUrlConfig"] = short_url + return body + + +def _validate_template_resources(client: SmsApiClient, body: Mapping[str, Any]) -> None: + signatures = client.call( + "ListSignatureForAgent", + { + "Signature": body["signatures"][0] if len(body["signatures"]) == 1 else "", + "SubAccounts": body["subAccounts"], + "Page": 1, + "PageSize": 100, + }, + ) + _require_query_success(signatures) + requested_groups = set(body["subAccounts"]) + available: Dict[str, Dict[str, set]] = {} + for item in _items(signatures): + if not _is_approved(item.get("Status")) or not _is_template_signature_usable( + item + ): + continue + name = _normalize_signature(str(item.get("Signature", ""))) + entry = available.setdefault(name, {"groups": set(), "channels": set()}) + entry["groups"].update((str(value) for value in item.get("SubAccounts", []))) + entry["channels"].update(_channels(item)) + for name in body["signatures"]: + if name not in available or not requested_groups.issubset( + available[name]["groups"] + ): + raise CliError( + "signature is not approved for every selected message group: {}".format( + name + ) + ) + channels = available[name]["channels"] + if channels and body["channelType"] not in channels: + raise CliError( + "signature is not approved for channel type: {}".format( + body["channelType"] + ) + ) + + +def _signature_preview( + body: Mapping[str, Any], message_groups: Sequence[Mapping[str, str]] +) -> Dict[str, Any]: + return { + "preview": dict(body), + "messageGroups": [dict(group) for group in message_groups], + "messageGroupCount": len(message_groups), + "digest": canonical_digest(body), + } + + +def _template_preview(body: Mapping[str, Any]) -> Dict[str, Any]: + preview: Dict[str, Any] = { + "name": body["name"], + "channelType": body["channelType"], + "area": body["area"], + "signatures": body["signatures"], + "subAccounts": body["subAccounts"], + "templateParams": [item["name"] for item in body["templateParams"]], + "content_length": len(body["content"]), + "content_sha256": hashlib.sha256(body["content"].encode("utf-8")).hexdigest(), + } + for name in ("project", "desc", "shortUrlConfig"): + if name in body: + preview[name] = body[name] + return {"preview": preview, "digest": canonical_digest(body)} + + +def _is_outcome_unknown(envelope: Mapping[str, Any]) -> bool: + error_value = envelope.get("error") + return bool( + not envelope.get("success") + and isinstance(error_value, Mapping) + and error_value.get("outcome_unknown") + ) + + +def _application_status_is_not_rejected(value: Any) -> bool: + if isinstance(value, int): + return value in {0, 1, 3, 5, 6} + return str(value).strip().lower() in { + "0", + "1", + "3", + "5", + "6", + "reviewing", + "pending", + "approved", + "passed", + "no_review", + "no-review", + "审核中", + "审核通过", + "免审", + } + + +def _matches_optional_fields( + body: Mapping[str, Any], + item: Mapping[str, Any], + aliases: Mapping[str, Sequence[str]], +) -> bool: + for body_name, response_names in aliases.items(): + if body_name not in body: + continue + actual = _template_value(item, *response_names) + if actual is None or actual != body[body_name]: + return False + return True + + +def _safe_template_item(item: Mapping[str, Any]) -> Dict[str, Any]: + safe = dict(item) + content = safe.pop("Content", None) + if content is None: + content = safe.pop("content", None) + if content is not None: + encoded = str(content).encode("utf-8") + safe["ContentLength"] = len(str(content)) + safe["ContentSha256"] = hashlib.sha256(encoded).hexdigest() + return safe + + +def _safe_template_envelope(envelope: Mapping[str, Any]) -> Dict[str, Any]: + output = dict(envelope) + result = output.get("result") + if not isinstance(result, Mapping): + return output + safe_result = dict(result) + for key in ("List", "list", "Items", "items"): + values = safe_result.get(key) + if isinstance(values, list): + safe_result[key] = [ + _safe_template_item(item) if isinstance(item, Mapping) else item + for item in values + ] + safe_result = _safe_template_item(safe_result) + output["result"] = safe_result + return output + + +def _reconciled_success( + action: str, query: Mapping[str, Any], summary: Mapping[str, Any] +) -> Dict[str, Any]: + return { + "success": True, + "action": action, + "request_id": query.get("request_id"), + "result": {"reconciled": True, **dict(summary)}, + "error": None, + } + + +def _reconcile_signature_application( + client: SmsApiClient, body: Mapping[str, Any], unknown: Mapping[str, Any] +) -> Dict[str, Any]: + query = client.call( + "ListSignatureForAgent", + { + "Signature": body["content"], + "SubAccounts": body["subAccounts"], + "Page": 1, + "PageSize": 100, + }, + ) + if not query.get("success"): + return dict(unknown) + requested_groups = {str(value) for value in body["subAccounts"]} + requested_channels = {str(value) for value in body["channelTypes"]} + matches = [] + for item in _items(query): + qualification_id = item.get("IdentificationId") or item.get("IdentificationID") + if ( + _normalize_signature(str(item.get("Signature", ""))) == body["content"] + and _application_status_is_not_rejected(item.get("Status")) + and requested_groups.issubset( + {str(value) for value in item.get("SubAccounts", [])} + ) + and requested_channels.issubset(_channels(item)) + and (str(qualification_id or "") == str(body["signatureIdentificationID"])) + and (str(item.get("Purpose") or "") == str(body["purpose"])) + and _matches_optional_fields( + body, + item, + { + "source": ("Source", "source"), + "desc": ("Description", "description", "desc"), + "domain": ("Domain", "domain"), + "scene": ("Scene", "scene"), + "projectName": ("ProjectName", "projectName"), + "appIcp": ("AppIcp", "appIcp"), + "trademark": ("Trademark", "trademark"), + }, + ) + ): + matches.append(item) + if len(matches) != 1: + return dict(unknown) + item = matches[0] + return _reconciled_success( + "ApplySmsSignatureV2", + query, + { + "Signature": body["content"], + "Status": item.get("Status"), + "SubAccounts": sorted(requested_groups), + "ChannelTypes": sorted(requested_channels), + }, + ) + + +def _reconcile_template_application( + client: SmsApiClient, body: Mapping[str, Any], unknown: Mapping[str, Any] +) -> Dict[str, Any]: + query = client.call( + "ListSmsTemplateForAgent", + { + "SubAccounts": body["subAccounts"], + "Signatures": body["signatures"], + "Page": 1, + "PageSize": 100, + }, + ) + if not query.get("success"): + return dict(unknown) + requested_groups = {str(value) for value in body["subAccounts"]} + requested_signatures = { + _normalize_signature(str(value)) for value in body["signatures"] + } + expected_params = sorted( + (str(value["name"]) for value in body.get("templateParams", [])) + ) + matches = [] + for item in _items(query): + name = item.get("TemplateName") or item.get("Name") or item.get("name") + content = item.get("Content") or item.get("content") + channel = item.get("ChannelType") or item.get("channelType") + try: + returned_signatures = _normalized_template_signatures(item) + except CliError: + return dict(unknown) + if ( + str(name or "") == str(body["name"]) + and _application_status_is_not_rejected(item.get("Status")) + and (str(content or "") == str(body["content"])) + and (str(channel or "") == str(body["channelType"])) + and requested_groups.issubset( + {str(value) for value in item.get("SubAccounts", [])} + ) + and (returned_signatures is not None) + and requested_signatures.issubset(returned_signatures) + and (sorted(_template_param_names(item)) == expected_params) + and _matches_optional_fields( + body, + item, + { + "project": ("Project", "project"), + "desc": ("Description", "description", "desc"), + "shortUrlConfig": ("ShortUrlConfig", "shortUrlConfig"), + }, + ) + ): + matches.append(item) + if len(matches) != 1: + return dict(unknown) + item = matches[0] + return _reconciled_success( + "ApplySmsTemplateV2", + query, + { + "TemplateId": item.get("TemplateId") or item.get("templateId"), + "TemplateName": body["name"], + "Status": item.get("Status"), + "ContentSha256": hashlib.sha256( + str(body["content"]).encode("utf-8") + ).hexdigest(), + }, + ) + + +def _normalize_mobile(value: str) -> str: + mobile = value.strip() + if mobile.startswith("+86"): + mobile = mobile[3:] + if not _MOBILE_RE.fullmatch(mobile): + raise CliError("invalid mainland China mobile number") + return mobile + + +def _mask_mobile(value: str) -> str: + return "{}****{}".format(value[:3], value[-4:]) + + +def _send_local_inputs( + args: argparse.Namespace, +) -> Tuple[List[str], List[str], Dict[str, Any]]: + if len(args.mobile) > MAX_DIRECT_RECIPIENTS: + raise CliError( + "direct send accepts at most {} recipients".format(MAX_DIRECT_RECIPIENTS) + ) + original = [_normalize_mobile(value) for value in args.mobile] + try: + variables = json.loads(args.template_params) + except json.JSONDecodeError as exc: + raise CliError("template-params must be a JSON object") from exc + if not isinstance(variables, dict): + raise CliError("template-params must be a JSON object") + if any((not isinstance(key, str) for key in variables)): + raise CliError("template parameter names must be strings") + seen = set() + recipients: List[str] = [] + duplicates: List[str] = [] + for mobile in original: + if mobile in seen: + duplicates.append(mobile) + continue + seen.add(mobile) + recipients.append(mobile) + if duplicates and args.dedupe_policy == "reject": + raise CliError("duplicate mobile numbers require dedupe-policy keep-first") + return (original, recipients, variables) + + +def _template_param_names(item: Mapping[str, Any]) -> List[str]: + raw = item.get("TemplateParams") or item.get("templateParams") or [] + names: List[str] = [] + for value in raw: + if isinstance(value, Mapping): + name = value.get("name") or value.get("Name") or value.get("ParamName") + else: + name = value + if name is not None: + names.append(str(name)) + return names + + +def _template_value(item: Mapping[str, Any], *names: str) -> Any: + for name in names: + value = item.get(name) + if value not in (None, "", []): + return value + return None + + +def _complete_template_for_send( + client: SmsApiClient, + template: Mapping[str, Any], + template_id: str, + signature: str, + sub_account: str, +) -> Dict[str, Any]: + completed = dict(template) + required = ( + ("Content", "content"), + ("SubAccounts", "subAccounts"), + ("ChannelType", "channelType"), + ) + has_template_params = any( + ( + name in completed and isinstance(completed[name], list) + for name in ("TemplateParams", "templateParams") + ) + ) + if ( + has_template_params + and all((_template_value(completed, *names) is not None for names in required)) + and str( + _template_value(completed, "TemplateName", "templateName", "Name", "name") + or "" + ).strip() + ): + return completed + second_template_id = str( + _template_value(completed, "SecondTemplateId", "secondTemplateId") or "" + ) + detail_params = {"templateId": template_id, "signatures": signature} + if second_template_id: + detail_params["secondTemplateId"] = second_template_id + detail = client.call("ListSecondTemplate", detail_params) + _require_query_success(detail) + candidate = next( + ( + item + for item in _items(detail) + if str( + _template_value(item, "TemplateId", "templateId", "SecondTemplateId") + ) + in {template_id, second_template_id} + and _template_supports_sub_account(item, sub_account) + and _template_supports_signature(item, signature) + ), + None, + ) + if candidate is None: + raise CliError("template detail is unavailable") + for target, source_names in ( + ("Content", ("Content", "content")), + ("TemplateParams", ("TemplateParams", "templateParams")), + ("Signatures", ("Signatures", "signatures", "Signature", "signature")), + ("SubAccounts", ("SubAccounts", "subAccounts")), + ("ChannelType", ("ChannelType", "channelType")), + ("TemplateName", ("TemplateName", "templateName", "Name", "name")), + ): + if completed.get(target) in (None, "", []): + if target == "Signatures" and any( + (name in completed for name in source_names) + ): + continue + if target == "TemplateParams": + resolved = next( + ( + candidate[name] + for name in source_names + if name in candidate and isinstance(candidate[name], list) + ), + None, + ) + if resolved is not None or any( + (name in completed for name in source_names) + ): + completed[target] = resolved + elif any((name in candidate for name in source_names)): + completed[target] = _template_value(candidate, *source_names) + has_template_params = any( + ( + name in completed and isinstance(completed[name], list) + for name in ("TemplateParams", "templateParams") + ) + ) + if not has_template_params or any( + (_template_value(completed, *names) is None for names in required) + ): + raise CliError("template detail is incomplete") + return completed + + +def _template_relationship_values(item: Mapping[str, Any], *names: str) -> set: + values = _template_value(item, *names) + if isinstance(values, str): + values = [values] + if not isinstance(values, (list, tuple, set)): + return set() + return {str(value) for value in values if value not in (None, "")} + + +def _template_supports_sub_account(item: Mapping[str, Any], sub_account: str) -> bool: + values = _template_relationship_values(item, "SubAccounts", "subAccounts") + return bool(values.intersection({sub_account, "*", "All"})) + + +def _normalized_template_signatures(item: Mapping[str, Any]) -> Optional[set]: + singular_names = ("Signature", "signature") + plural_names = ("Signatures", "signatures") + present = [name for name in singular_names + plural_names if name in item] + if not present: + return None + normalized: Dict[str, set] = {} + for name in present: + raw = item[name] + values = [raw] if isinstance(raw, str) else raw + if not isinstance(values, (list, tuple, set)): + values = [] + normalized[name] = { + _normalize_signature(str(value)) + for value in values + if value not in (None, "") + } + for names in (singular_names, plural_names): + family = [normalized[name] for name in names if name in normalized] + if len(family) > 1 and any((value != family[0] for value in family[1:])): + raise CliError("template signature aliases conflict", "contract_conflict") + singular = next( + (normalized[name] for name in singular_names if name in normalized), None + ) + plural = next( + (normalized[name] for name in plural_names if name in normalized), None + ) + if singular is not None and plural is not None: + if singular != plural: + raise CliError("template signature aliases conflict", "contract_conflict") + return singular + return singular if singular is not None else plural + + +def _template_supports_signature(item: Mapping[str, Any], signature: str) -> bool: + values = _normalized_template_signatures(item) + return True if values is None else signature in values + + +def _signature_supports_sub_account(item: Mapping[str, Any], sub_account: str) -> bool: + values = _template_relationship_values(item, "SubAccounts", "subAccounts") + return not values or bool(values.intersection({sub_account, "*", "All"})) + + +def _template_content_body(content: str, signature: str) -> str: + wrapper = "【{}】".format(signature) + return content[len(wrapper) :] if content.startswith(wrapper) else content + + +def _match_template(client: SmsApiClient, args: argparse.Namespace) -> Dict[str, Any]: + signature = _normalize_signature(args.signature) + approved_ids = set() + seen_page_fingerprints = set() + page = 1 + while True: + if page > MAX_TEMPLATE_MATCH_PAGES: + raise CliError( + "template pagination exceeded the safe page limit", + "resource_query_failed", + ) + templates = client.call( + "ListSmsTemplateForAgent", + { + "SubAccounts": [args.sub_account], + "Signatures": [signature], + "Page": page, + "PageSize": 100, + }, + ) + _require_query_success(templates) + template_items = _items(templates) + fingerprint = canonical_digest({"items": template_items}) + if fingerprint in seen_page_fingerprints: + raise CliError( + "template pagination repeated a page", "resource_query_failed" + ) + seen_page_fingerprints.add(fingerprint) + approved_ids.update( + ( + str(_template_value(item, "TemplateId", "templateId")) + for item in template_items + if _template_value(item, "TemplateId", "templateId") is not None + and _is_approved(_template_value(item, "Status", "status")) + and ( + _template_value(item, "ChannelType", "channelType") + == args.channel_type + ) + ) + ) + result = templates.get("result") + total = ( + result.get("Total", result.get("total")) + if isinstance(result, Mapping) + else None + ) + if isinstance(total, str) and total.strip().isdigit(): + total = int(total.strip()) + if len(template_items) < 100 or ( + isinstance(total, int) + and (not isinstance(total, bool)) + and (page * 100 >= total) + ): + break + page += 1 + candidates: Dict[str, Dict[str, Any]] = {} + if approved_ids: + details = client.call("ListSecondTemplate", {"signatures": signature}) + _require_query_success(details) + for item in _items(details): + template_id = _template_value(item, "TemplateId", "templateId") + content = _template_value(item, "Content", "content") + if ( + template_id is None + or str(template_id) not in approved_ids + or _template_value(item, "ChannelType", "channelType") + != args.channel_type + or (not isinstance(content, str)) + or ( + not any( + (name in item for name in ("TemplateParams", "templateParams")) + ) + ) + or (not _template_supports_sub_account(item, args.sub_account)) + or (not _template_supports_signature(item, signature)) + ): + continue + body = _template_content_body(content, signature) + if body != args.content: + continue + variable_names = _template_param_names(item) + if len(variable_names) != len(set(variable_names)): + continue + normalized_id = str(template_id) + candidates.setdefault( + normalized_id, + { + "templateId": normalized_id, + "signature": signature, + "subAccount": args.sub_account, + "variableNames": sorted(variable_names), + "contentLength": len(body), + "contentSha256": hashlib.sha256(body.encode("utf-8")).hexdigest(), + }, + ) + ordered = [candidates[key] for key in sorted(candidates)] + classification = ( + "none" if not ordered else "single" if len(ordered) == 1 else "ambiguous" + ) + return _local_success( + args.command, {"classification": classification, "candidates": ordered} + ) + + +def _render_content(content: str, variables: Mapping[str, Any]) -> str: + return _VARIABLE_RE.sub(lambda match: str(variables[match.group(1)]), content) + + +def _segments(content: str, signature: str) -> int: + length = len("【{}】{}".format(signature, content)) + return 1 if length <= 70 else int(math.ceil(length / 67.0)) + + +def _direct_send_template_detail( + client: SmsApiClient, template_id: str, signature: str, sub_account: str +) -> Mapping[str, Any]: + detail = client.call( + "ListSecondTemplate", {"templateId": template_id, "signatures": signature} + ) + _require_query_success(detail) + selected = next( + ( + item + for item in _items(detail) + if str(_template_value(item, "TemplateId", "templateId")) == template_id + and _template_supports_sub_account(item, sub_account) + and _template_supports_signature(item, signature) + ), + None, + ) + if selected is None: + raise CliError("template detail does not match message group and signature") + if not any((name in selected for name in ("TemplateParams", "templateParams"))): + raise CliError("template detail is incomplete") + content = _template_value(selected, "Content", "content") + channel_type = _template_value(selected, "ChannelType", "channelType") + if not isinstance(content, str) or channel_type not in { + "CN_OTP", + "CN_NTC", + "CN_MKT", + }: + raise CliError("template detail is incomplete") + normalized = dict(selected) + normalized["Content"] = _template_content_body(content, signature) + return normalized + + +def _send_summary( + client: SmsApiClient, args: argparse.Namespace +) -> Tuple[Dict[str, Any], Dict[str, Any], List[str], Dict[str, Any]]: + original, recipients, variables = _send_local_inputs(args) + signature = _normalize_signature(args.signature) + group = client.call("GetSubAccountDetail", {"subAccount": args.sub_account}) + _require_query_success(group) + group_result = group.get("result") + if not isinstance(group_result, Mapping) or str(group_result.get("status")) != "1": + raise CliError("message group is unavailable") + signatures = client.call( + "ListSignatureForAgent", + { + "Signature": signature, + "SubAccounts": [args.sub_account], + "Page": 1, + "PageSize": 100, + }, + ) + _require_query_success(signatures) + selected_signature = next( + ( + item + for item in _items(signatures) + if _is_approved(item.get("Status")) + and _is_template_signature_usable(item) + and (_normalize_signature(str(item.get("Signature", ""))) == signature) + and _signature_supports_sub_account(item, args.sub_account) + ), + None, + ) + if selected_signature is None: + raise CliError("signature is not approved for the selected message group") + templates = client.call( + "ListSmsTemplateForAgent", + { + "TemplateId": args.template_id, + "SubAccounts": [args.sub_account], + "Signatures": [signature], + "Page": 1, + "PageSize": 100, + }, + ) + _require_query_success(templates) + selected_template = next( + ( + item + for item in _items(templates) + if str(_template_value(item, "TemplateId", "templateId")) + == args.template_id + and _is_approved(_template_value(item, "Status", "status")) + ), + None, + ) + if selected_template is None: + raise CliError("template is not approved or available") + selected_template = _direct_send_template_detail( + client, args.template_id, signature, args.sub_account + ) + expected_variables = _template_param_names(selected_template) + if len(set(expected_variables)) != len(expected_variables): + raise CliError("template metadata contains duplicate variables") + if set(variables) != set(expected_variables): + raise CliError( + "template parameter values must exactly match template variables" + ) + content = str( + selected_template.get("Content") or selected_template.get("content") or "" + ) + if set(_VARIABLE_RE.findall(content)) != set(expected_variables): + raise CliError("template content and variable metadata do not match") + rendered = _render_content(content, variables) + estimated_each = _segments(rendered, signature) + duplicates = [ + value for index, value in enumerate(original) if value in original[:index] + ] + canonical: Dict[str, Any] = { + "subAccount": args.sub_account, + "signature": signature, + "templateId": args.template_id, + "originalRecipients": original, + "dedupePolicy": args.dedupe_policy, + "duplicates": duplicates, + "recipients": recipients, + "recipientVariables": [ + {"mobile": mobile, "variables": variables} for mobile in recipients + ], + "templateContentSha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), + "renderedContentSha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(), + "estimatedSegments": estimated_each * len(recipients), + } + preview: Dict[str, Any] = { + "subAccount": args.sub_account, + "signature": signature, + "templateId": args.template_id, + "originalRecipients": [_mask_mobile(value) for value in original], + "recipients": [_mask_mobile(value) for value in recipients], + "recipientCount": len(recipients), + "dedupePolicy": args.dedupe_policy, + "duplicates": [_mask_mobile(value) for value in duplicates], + "variableNames": sorted(variables), + "contentSummary": { + "templateSha256": canonical["templateContentSha256"], + "renderedSha256": canonical["renderedContentSha256"], + }, + "estimatedSegments": canonical["estimatedSegments"], + } + return (canonical, preview, recipients, variables) + + +def _sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with pathlib.Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _read_batch_snapshot(path: pathlib.Path) -> bytes: + try: + with pathlib.Path(path).open("rb") as stream: + data = stream.read(MAX_BATCH_FILE_BYTES + 1) + except OSError as exc: + raise CliError("cannot read batch CSV") from exc + if len(data) > MAX_BATCH_FILE_BYTES: + raise CliError("batch CSV exceeds 50 MB") + return data + + +def precheck_batch_csv( + path_value: Any, template_variables: Sequence[str] +) -> Dict[str, Any]: + if isinstance(path_value, (bytes, bytearray)): + snapshot = bytes(path_value) + file_size = len(snapshot) + file_sha256 = hashlib.sha256(snapshot).hexdigest() + stream = io.TextIOWrapper( + io.BytesIO(snapshot), encoding="utf-8-sig", newline="" + ) + else: + path = pathlib.Path(path_value) + if path.suffix.lower() != ".csv": + raise CliError("batch v1 supports CSV files only") + try: + file_size = path.stat().st_size + file_sha256 = _sha256_file(path) + stream = path.open("r", encoding="utf-8-sig", newline="") + except OSError as exc: + raise CliError("cannot read batch CSV") from exc + if not file_size: + stream.close() + raise CliError("batch CSV is empty") + if file_size > MAX_BATCH_FILE_BYTES: + stream.close() + raise CliError("batch CSV exceeds 50 MB") + try: + rows = csv.DictReader(stream) + columns = rows.fieldnames + except (csv.Error, UnicodeDecodeError, OSError) as exc: + stream.close() + raise CliError("batch CSV is invalid") from exc + expected = ["phone"] + list(template_variables) + if columns != expected: + stream.close() + raise CliError( + "batch CSV columns must exactly be: {}".format(",".join(expected)) + ) + seen = set() + count = 0 + try: + for row in rows: + if None in row or any((value is None for value in row.values())): + raise CliError("batch CSV contains an invalid or extra column") + if not any((str(value).strip() for value in row.values())): + raise CliError("batch CSV contains a blank row") + mobile = _normalize_mobile(str(row["phone"])) + if mobile in seen: + raise CliError("batch CSV contains duplicate mobile numbers") + seen.add(mobile) + if any((not str(row[name]).strip() for name in template_variables)): + raise CliError("batch CSV contains an empty template variable") + count += 1 + if count > MAX_BATCH_ROWS: + raise CliError("batch CSV exceeds 1,000,000 rows") + except UnicodeDecodeError as exc: + raise CliError("batch CSV must be UTF-8") from exc + except csv.Error as exc: + raise CliError("batch CSV is invalid") from exc + finally: + stream.close() + if count == 0: + raise CliError("batch CSV has no recipients") + return { + "fileSha256": file_sha256, + "fileSize": file_size, + "columns": expected, + "totalCount": count, + "validCount": count, + "invalidCount": 0, + "dupCount": 0, + } + + +def _next_month(value: datetime.datetime) -> datetime.datetime: + year = value.year + (1 if value.month == 12 else 0) + month = 1 if value.month == 12 else value.month + 1 + day = min(value.day, calendar.monthrange(year, month)[1]) + return value.replace(year=year, month=month, day=day) + + +def validate_batch_schedule( + scheduled: bool, send_time: Optional[str], now: datetime.datetime +) -> int: + current = now.astimezone(CHINA_TZ) + if not scheduled: + if not datetime.time(8, 0) <= current.time() <= datetime.time(21, 30): + raise CliError("immediate batch send is outside 08:00-21:30 Asia/Shanghai") + return 0 + if not send_time: + raise CliError("scheduled batch task requires send-time") + try: + parsed = datetime.datetime.fromisoformat(send_time) + except ValueError as exc: + raise CliError("send-time must be ISO 8601") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=CHINA_TZ) + parsed = parsed.astimezone(CHINA_TZ) + if parsed <= current: + raise CliError("scheduled send-time must be in the future") + if parsed > _next_month(current): + raise CliError("scheduled send-time must be no more than one month ahead") + if not datetime.time(8, 0) <= parsed.time() <= datetime.time(21, 30): + raise CliError("scheduled send-time must be within 08:00-21:30 Asia/Shanghai") + return int(parsed.timestamp()) + + +def _task_send_time(value: Any) -> Optional[datetime.datetime]: + if value in (None, "", 0, "0"): + return None + if isinstance(value, (int, float)) and (not isinstance(value, bool)): + return datetime.datetime.fromtimestamp(float(value), tz=CHINA_TZ) + try: + parsed = datetime.datetime.fromisoformat(str(value)) + except ValueError as exc: + raise CliError("batch task send time is invalid") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=CHINA_TZ) + return parsed.astimezone(CHINA_TZ) + + +def _default_uploader(url: str, source: Any) -> None: + parsed = parse.urlsplit(url) + hostname = (parsed.hostname or "").lower() + if ( + parsed.scheme != "https" + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or (not hostname.endswith(".volces.com")) + ): + raise CliError("batch upload URL is not an approved Volcengine TOS URL") + + class _NoRedirect(request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise CliError("batch upload redirect is not allowed") + + data = ( + bytes(source) + if isinstance(source, (bytes, bytearray)) + else pathlib.Path(source).read_bytes() + ) + upload = request.Request(url, data=data, method="PUT") + upload.add_header("Content-Length", str(len(data))) + with request.build_opener(_NoRedirect).open(upload, timeout=60) as response: + if not 200 <= int(response.getcode()) < 300: + raise CliError("batch file upload failed") + + +def _batch_resources( + client: SmsApiClient, sub_account: str, signature_value: str, template_id: str +) -> Tuple[Mapping[str, Any], List[str]]: + signature = _normalize_signature(signature_value) + groups = client.call("ListSubAccountForAgent", {}) + _require_query_success(groups) + group = next( + (item for item in _items(groups) if str(item.get("SubAccount")) == sub_account), + None, + ) + if group is None: + raise CliError("message group is unavailable") + signatures = client.call( + "ListSignatureForAgent", + { + "Signature": signature, + "SubAccounts": [sub_account], + "Page": 1, + "PageSize": 100, + }, + ) + _require_query_success(signatures) + signature_item = next( + ( + item + for item in _items(signatures) + if _is_approved(item.get("Status")) + and _is_template_signature_usable(item) + and (_normalize_signature(str(item.get("Signature", ""))) == signature) + and (sub_account in {str(value) for value in item.get("SubAccounts", [])}) + ), + None, + ) + if signature_item is None: + raise CliError("signature is unavailable for the message group") + qualification_id = signature_item.get("IdentificationId") or signature_item.get( + "IdentificationID" + ) + if qualification_id is not None: + qualifications = client.call( + "GetSignatureIdentificationList", + {"id": qualification_id, "pageIndex": 1, "pageSize": 100}, + ) + _require_query_success(qualifications) + qualification = next( + ( + item + for item in _items(qualifications) + if str(item.get("id")) == str(qualification_id) + ), + None, + ) + if ( + qualification is None + or not qualification.get("usable") + or (not _is_approved(qualification.get("auditStatus"))) + ): + raise CliError("signature qualification is no longer approved and usable") + templates = client.call( + "ListSmsTemplateForAgent", + { + "TemplateId": template_id, + "SubAccounts": [sub_account], + "Signatures": [signature], + "Page": 1, + "PageSize": 100, + }, + ) + _require_query_success(templates) + template = next( + ( + item + for item in _items(templates) + if str(item.get("TemplateId")) == template_id + and _is_approved(item.get("Status")) + ), + None, + ) + if template is None: + raise CliError("template is unavailable") + template = _complete_template_for_send( + client, template, template_id, signature, sub_account + ) + channel = str(template.get("ChannelType") or "") + if channel == "CN_OTP": + raise CliError("OTP templates cannot be used for batch tasks") + if channel not in {"CN_NTC", "CN_MKT"}: + raise CliError("batch task requires a notification or marketing template") + signature_channels = _channels(signature_item) + if signature_channels and channel not in { + str(value) for value in signature_channels + }: + raise CliError("signature does not support the batch template channel type") + group_channels = _channels(group) + if group_channels and channel not in {str(value) for value in group_channels}: + raise CliError("message group does not support the batch template channel type") + if not _template_supports_sub_account(template, sub_account): + raise CliError("template is not bound to the message group") + if not _template_supports_signature(template, signature): + raise CliError("template is not bound to the signature") + template_name = str(template.get("TemplateName") or "").strip() + if not template_name: + template_name = template_id + template = dict(template) + template["TemplateName"] = template_name + return (template, _template_param_names(template)) + + +def _batch_task_data(envelope: Mapping[str, Any]) -> Mapping[str, Any]: + _require_query_success(envelope) + result = envelope.get("result") + if not isinstance(result, Mapping): + raise CliError("batch task detail is missing") + return result + + +def _first_not_none(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + +def _batch_summary(task: Mapping[str, Any]) -> Dict[str, Any]: + extra = task.get("extra") or task.get("Extra") or {} + if not isinstance(extra, Mapping): + extra = {} + return { + "taskId": str(task.get("taskId") or task.get("TaskId") or ""), + "taskName": task.get("taskName") + or task.get("TaskName") + or task.get("name") + or task.get("Name"), + "subAccount": str(task.get("subAccount") or task.get("SubAccount") or ""), + "signature": task.get("signature") or task.get("Signature"), + "templateId": task.get("templateId") or task.get("TemplateId"), + "templateName": task.get("templateName") or task.get("TemplateName"), + "channelType": task.get("channelType") or task.get("ChannelType"), + "fileUrl": task.get("fileUrl") or task.get("FileUrl"), + "fileSha256": extra.get("fileSha256"), + "totalCount": _first_not_none(extra.get("totalCount"), task.get("totalCount")), + "validCount": _first_not_none(extra.get("validCount"), task.get("validCount")), + "invalidCount": _first_not_none( + extra.get("invalidCount"), task.get("invalidCount") + ), + "dupCount": _first_not_none(extra.get("dupCount"), task.get("dupCount")), + "contentSha256": extra.get("contentSha256"), + "scheduled": task.get("scheduled"), + "sendTime": task.get("sendTime") or task.get("SendTime"), + } + + +def _launch_summary( + client: SmsApiClient, args: argparse.Namespace, task: Mapping[str, Any] +) -> Dict[str, Any]: + summary = _batch_summary(task) + if summary["taskId"] != args.task_id or summary["subAccount"] != args.sub_account: + raise CliError("batch task identity does not match") + template_id = str(summary["templateId"] or "") + signature = str(summary["signature"] or "") + if not template_id or not signature: + raise CliError("batch task resource identity is incomplete") + template, _ = _batch_resources(client, args.sub_account, signature, template_id) + current_content_sha = hashlib.sha256( + str(template.get("Content") or "").encode("utf-8") + ).hexdigest() + if summary["contentSha256"] not in (None, current_content_sha): + raise CliError("batch task template content changed", "digest_mismatch") + summary["contentSha256"] = current_content_sha + for summary_name, template_name in ( + ("templateName", "TemplateName"), + ("channelType", "ChannelType"), + ): + current = str(template.get(template_name) or "") + if summary[summary_name] not in (None, "", current): + raise CliError("batch task template metadata changed", "digest_mismatch") + summary[summary_name] = current + handoff = { + "fileSha256": args.file_sha256, + "totalCount": args.total_count, + "validCount": args.valid_count, + "invalidCount": args.invalid_count, + "dupCount": args.dup_count, + } + for name, supplied in handoff.items(): + if supplied is not None: + if summary[name] is not None and str(summary[name]) != str(supplied): + raise CliError( + "batch task handoff metadata changed: {}".format(name), + "digest_mismatch", + ) + summary[name] = supplied + missing = [ + name + for name in ( + "fileSha256", + "totalCount", + "validCount", + "invalidCount", + "dupCount", + ) + if summary[name] is None + ] + if missing: + raise CliError( + "provide creation handoff metadata for launch: {}".format( + ", ".join(missing) + ) + ) + return summary + + +def _query_params(args: argparse.Namespace) -> Tuple[str, Dict[str, Any]]: + if args.command == "list-message-groups": + return ( + "ListSubAccountForAgent", + {"SubAccountName": args.name} if args.name else {}, + ) + if args.command == "message-group-detail": + return ("GetSubAccountDetail", {"subAccount": args.sub_account}) + if args.command == "list-qualifications": + params: Dict[str, Any] = {"pageIndex": args.page, "pageSize": args.page_size} + if args.qualification_id is not None: + params["id"] = args.qualification_id + if args.material_name: + params["materialName"] = args.material_name + if args.status: + params["status"] = args.status + return ("GetSignatureIdentificationList", params) + if args.command == "list-signatures": + params = {"Page": args.page, "PageSize": args.page_size} + if args.signature: + params["Signature"] = _normalize_signature(args.signature) + if args.sub_account: + params["SubAccounts"] = args.sub_account + return ("ListSignatureForAgent", params) + if args.command == "list-templates": + params = {"Page": args.page, "PageSize": args.page_size} + if args.template_id: + params["TemplateId"] = args.template_id + if args.sub_account: + params["SubAccounts"] = args.sub_account + if args.signature: + params["Signatures"] = [ + _normalize_signature(value) for value in args.signature + ] + return ("ListSmsTemplateForAgent", params) + raise CliError("unknown query command") + + +def execute( + args: argparse.Namespace, + client: SmsApiClient, + *, + uploader: Any = _default_uploader, + now: Any = None, +) -> Dict[str, Any]: + if args.command == "analytics": + try: + report = build_report( + client, + start=args.start, + end=args.end, + sub_account=args.sub_account, + channel_type=args.channel_type, + signature=args.signature, + template_id=args.template_id, + mobile=_normalize_mobile(args.mobile) + if args.mobile is not None + else None, + bucket=args.bucket, + include_logs=args.include_logs, + page_size=args.page_size, + max_pages=args.max_pages, + dimension=args.dimension, + ) + except AnalyticsError as exc: + raise CliError(str(exc), exc.code) from exc + return _local_success(args.command, report) + if args.command == "match-template": + return _match_template(client, args) + if args.command.startswith("list-") or args.command == "message-group-detail": + action, params = _query_params(args) + result = client.call(action, params) + return ( + _safe_template_envelope(result) + if action == "ListSmsTemplateForAgent" + else result + ) + if args.command in {"signature-preview", "signature-submit"}: + body = _signature_body(args) + message_groups = _validate_signature_resources( + client, + body["signatureIdentificationID"], + body["purpose"], + body["subAccounts"], + body["channelTypes"], + ) + digest = canonical_digest(body) + if args.command == "signature-preview": + return _local_success( + args.command, _signature_preview(body, message_groups) + ) + if not hmac.compare_digest(args.preview_digest, digest): + raise CliError( + "input changed after preview; generate a new preview", "digest_mismatch" + ) + submitted = client.call("ApplySmsSignatureV2", body) + if _is_outcome_unknown(submitted): + return _reconcile_signature_application(client, body, submitted) + return submitted + if args.command in {"template-preview", "template-submit"}: + body = _template_body(args) + _validate_template_resources(client, body) + digest = canonical_digest(body) + if args.command == "template-preview": + return _local_success(args.command, _template_preview(body)) + if not hmac.compare_digest(args.preview_digest, digest): + raise CliError( + "input changed after preview; generate a new preview", "digest_mismatch" + ) + submitted = client.call("ApplySmsTemplateV2", body) + if _is_outcome_unknown(submitted): + return _reconcile_template_application(client, body, submitted) + return _safe_template_envelope(submitted) + if args.command == "send-status": + return client.call( + "ListSmsSendLogForAgent", + { + "SubAccount": args.sub_account, + "MessageId": args.message_id, + "Page": args.page, + "PageSize": args.page_size, + }, + ) + if args.command in {"send-preview", "send-submit"}: + if args.command == "send-submit": + if not hmac.compare_digest(args.preview_digest, args.authorization_digest): + raise CliError( + "authorization is not bound to this preview", + "authorization_mismatch", + ) + canonical, preview, recipients, variables = _send_summary(client, args) + digest = canonical_digest(canonical) + if args.command == "send-preview": + return _local_success(args.command, {"preview": preview, "digest": digest}) + if not hmac.compare_digest(args.preview_digest, digest): + raise CliError( + "send input or resource state changed after preview", "digest_mismatch" + ) + return client.call( + "SendSmsForAgent", + { + "SubAccount": args.sub_account, + "Signature": canonical["signature"], + "TemplateId": args.template_id, + "Mobiles": ",".join(recipients), + "TemplateParam": json.dumps( + variables, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ), + }, + ) + if args.command == "batch-template-demo": + params = {"subAccount": args.sub_account, "templateId": args.template_id} + if args.force_update: + params["forceUpdate"] = True + return client.call("TemplateUploadDemo", params) + if args.command == "batch-detail": + return client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": args.task_id}, + ) + if args.command == "batch-list": + params = { + "subAccount": args.sub_account, + "pageIndex": args.page, + "pageSize": args.page_size, + } + for target, value in ( + ("taskName", args.task_name), + ("signature", args.signature), + ("templateId", args.template_id), + ): + if value: + params[target] = value + return client.call("GetBatchTaskList", params) + if args.command == "batch-precheck": + report = precheck_batch_csv(pathlib.Path(args.file), args.template_param or []) + return _local_success(args.command, report) + if args.command == "batch-create": + current = (now or (lambda: datetime.datetime.now(CHINA_TZ)))() + send_time = validate_batch_schedule(args.scheduled, args.send_time, current) + template, variables = _batch_resources( + client, args.sub_account, args.signature, args.template_id + ) + source_path = pathlib.Path(args.file) + if source_path.suffix.lower() != ".csv": + raise CliError("batch v1 supports CSV files only") + snapshot = _read_batch_snapshot(source_path) + report = precheck_batch_csv(snapshot, variables) + upload = client.call( + "GetUploadTosURL", {"suffix": "csv"}, preserve_presigned_url=True + ) + upload_data = _batch_task_data(upload) + upload_url = upload_data.get("url") or upload_data.get("Url") + file_key = upload_data.get("file") or upload_data.get("File") + if not upload_url or not file_key: + raise CliError("upload authorization is incomplete") + try: + uploader(str(upload_url), snapshot) + except CliError: + raise + except Exception as exc: + raise CliError("batch file upload failed") from exc + try: + current_file_sha = hashlib.sha256(snapshot).hexdigest() + except (TypeError, ValueError) as exc: + raise CliError("batch CSV snapshot is invalid") from exc + if not hmac.compare_digest(current_file_sha, report["fileSha256"]): + raise CliError("batch CSV snapshot changed during upload") + content = str(template.get("Content") or "") + handoff_metadata = { + "fileSha256": report["fileSha256"], + "totalCount": report["totalCount"], + "validCount": report["validCount"], + "invalidCount": report["invalidCount"], + "dupCount": report["dupCount"], + "contentSha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), + } + body = { + "subAccount": args.sub_account, + "name": args.task_name, + "signature": _normalize_signature(args.signature), + "templateId": args.template_id, + "templateName": str(template["TemplateName"]), + "channelType": str(template["ChannelType"]), + "scheduled": args.scheduled, + "sendTime": send_time, + "fileUrl": str(file_key), + "extra": {}, + } + created = client.call("SetBatchTask", body) + if not created.get("success"): + return created + service_result = created.get("result") + if not isinstance(service_result, Mapping): + raise CliError("batch create response is incomplete") + task_id = service_result.get("taskId") or service_result.get("TaskId") + if not task_id: + raise CliError("batch create response has no taskId") + authoritative = client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": str(task_id)}, + ) + authoritative_result = ( + authoritative.get("result") if authoritative.get("success") else None + ) + status = None + if isinstance(authoritative_result, Mapping): + detail_id = authoritative_result.get("taskId") or authoritative_result.get( + "TaskId" + ) + detail_group = authoritative_result.get( + "subAccount" + ) or authoritative_result.get("SubAccount") + if ( + str(detail_id or "") == str(task_id) + and str(detail_group or "") == args.sub_account + ): + status = authoritative_result.get("status") + if status is None: + status = authoritative_result.get("Status") + handoff = dict(service_result) + handoff.update( + { + "taskId": str(task_id), + "subAccount": args.sub_account, + "status": status, + "statusAuthoritative": status is not None, + "fileSha256": report["fileSha256"], + "totalCount": service_result.get("totalCount", report["totalCount"]), + "validCount": service_result.get("validCount", report["validCount"]), + "invalidCount": service_result.get( + "invalidCount", report["invalidCount"] + ), + "dupCount": service_result.get("dupCount", report["dupCount"]), + "digest": canonical_digest( + { + "taskId": str(task_id), + "subAccount": args.sub_account, + "body": body, + "handoff": handoff_metadata, + } + ), + } + ) + return { + "success": True, + "action": "SetBatchTask", + "request_id": created.get("request_id"), + "result": handoff, + "error": None, + } + if args.command in {"batch-launch-preview", "batch-launch-submit"}: + if args.command == "batch-launch-submit": + expected_text = "确认启动任务 {}".format(args.task_id) + if args.authorization_text != expected_text: + raise CliError( + "authorization must exactly be: {}".format(expected_text), + "authorization_mismatch", + ) + first = client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": args.task_id}, + ) + task = _batch_task_data(first) + summary = _batch_summary(task) + if ( + summary["taskId"] != args.task_id + or summary["subAccount"] != args.sub_account + ): + raise CliError("batch task identity does not match") + status = int(task.get("status", task.get("Status", -1))) + if 3 <= status <= 6: + return _local_success( + args.command, + {"alreadyStarted": True, "status": status, "taskId": args.task_id}, + ) + if status != 2: + raise CliError("batch task is not in Valid(2) state") + summary = _launch_summary(client, args, task) + digest = canonical_digest(summary) + preview = { + "taskId": args.task_id, + "subAccount": args.sub_account, + "taskName": summary["taskName"], + "signature": summary["signature"], + "templateId": summary["templateId"], + "channelType": summary["channelType"], + "fileKeySha256": hashlib.sha256( + str(summary["fileUrl"] or "").encode("utf-8") + ).hexdigest(), + "fileSha256": summary["fileSha256"], + "contentSha256": summary["contentSha256"], + "totalCount": summary["totalCount"], + "validCount": summary["validCount"], + "invalidCount": summary["invalidCount"], + "dupCount": summary["dupCount"], + "sendTime": summary["sendTime"], + } + if args.command == "batch-launch-preview": + return _local_success(args.command, {"preview": preview, "digest": digest}) + if not hmac.compare_digest(args.preview_digest, digest): + raise CliError("batch task changed after preview", "digest_mismatch") + second = client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": args.task_id}, + ) + latest = _batch_task_data(second) + latest_status = int(latest.get("status", latest.get("Status", -1))) + if 3 <= latest_status <= 6: + return _local_success( + args.command, + { + "alreadyStarted": True, + "status": latest_status, + "taskId": args.task_id, + }, + ) + latest_summary = _launch_summary(client, args, latest) + if latest_status != 2 or canonical_digest(latest_summary) != digest: + raise CliError("batch task changed before launch", "digest_mismatch") + launched = client.call( + "ConsentBatchTask", {"subAccount": args.sub_account, "taskId": args.task_id} + ) + if ( + not launched.get("success") + and isinstance(launched.get("error"), Mapping) + and launched["error"].get("outcome_unknown") + ): + reconciled = client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": args.task_id}, + ) + if reconciled.get("success"): + reconciled_task = _batch_task_data(reconciled) + reconciled_status = int( + reconciled_task.get("status", reconciled_task.get("Status", -1)) + ) + if 3 <= reconciled_status <= 6: + return _local_success( + args.command, + { + "reconciled": True, + "status": reconciled_status, + "taskId": args.task_id, + }, + ) + return launched + if args.command == "batch-cancel": + detail = client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": args.task_id}, + ) + task = _batch_task_data(detail) + summary = _batch_summary(task) + if ( + summary["taskId"] != args.task_id + or summary["subAccount"] != args.sub_account + ): + raise CliError("batch task identity does not match") + status = int(task.get("status", task.get("Status", -1))) + if status == 7: + return _local_success( + args.command, + {"alreadyCanceled": True, "status": 7, "taskId": args.task_id}, + ) + if status not in {0, 1, 2, 3, 4, 5}: + raise CliError("batch task can no longer be canceled") + if bool(task.get("scheduled", task.get("Scheduled", False))): + send_at = _task_send_time(summary["sendTime"]) + current = (now or (lambda: datetime.datetime.now(CHINA_TZ)))().astimezone( + CHINA_TZ + ) + if send_at is None or send_at <= current + CANCEL_LEAD_TIME: + raise CliError( + "batch task is inside the one-minute cancellation cutoff" + ) + canceled = client.call( + "DeleteBatchTask", {"subAccount": args.sub_account, "taskId": args.task_id} + ) + if ( + not canceled.get("success") + and isinstance(canceled.get("error"), Mapping) + and canceled["error"].get("outcome_unknown") + ): + reconciled = client.call( + "GetBatchTaskDetail", + {"subAccount": args.sub_account, "taskId": args.task_id}, + ) + if reconciled.get("success"): + reconciled_task = _batch_task_data(reconciled) + if ( + int( + reconciled_task.get("status", reconciled_task.get("Status", -1)) + ) + == 7 + ): + return _local_success( + args.command, + {"reconciled": True, "status": 7, "taskId": args.task_id}, + ) + return canceled + raise CliError("unsupported command", "argument_error") + + +def _bounded_int(name: str, minimum: int, maximum: int): + + def parse_value(raw: str) -> int: + value = int(raw) + if not minimum <= value <= maximum: + raise argparse.ArgumentTypeError( + "{} must be between {} and {}".format(name, minimum, maximum) + ) + return value + + return parse_value + + +def _non_empty_text(name: str): + + def parse_value(raw: str) -> str: + value = raw.strip() + if not value: + raise argparse.ArgumentTypeError("{} must not be empty".format(name)) + return value + + return parse_value + + +def _sha256_value(raw: str) -> str: + value = raw.strip().lower() + if not re.fullmatch("[0-9a-f]{64}", value): + raise argparse.ArgumentTypeError( + "file-sha256 must be 64 hexadecimal characters" + ) + return value + + +def _page_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--page", type=_bounded_int("page", 1, 100000), default=1) + parser.add_argument( + "--page-size", type=_bounded_int("page-size", 1, 100), default=100 + ) + + +def _signature_application_options( + parser: argparse.ArgumentParser, *, submit: bool +) -> None: + parser.add_argument("--content", required=True) + parser.add_argument("--purpose", required=True, type=int, choices=(1, 2)) + parser.add_argument("--qualification-id", required=True, type=int) + parser.add_argument("--sub-account", required=True, action="append") + parser.add_argument( + "--channel-type", + required=True, + action="append", + choices=("CN_OTP", "CN_NTC", "CN_MKT"), + ) + parser.add_argument("--source", required=True, type=int, choices=(1, 2, 3)) + parser.add_argument("--description") + parser.add_argument("--domain") + parser.add_argument("--scene") + parser.add_argument("--project-name") + parser.add_argument("--app-icp") + parser.add_argument("--trademark") + if submit: + parser.add_argument("--preview-digest", required=True) + + +def _template_application_options( + parser: argparse.ArgumentParser, *, submit: bool +) -> None: + parser.add_argument("--name", required=True) + parser.add_argument("--content", required=True) + parser.add_argument( + "--channel-type", required=True, choices=("CN_OTP", "CN_NTC", "CN_MKT") + ) + parser.add_argument("--signature", required=True, action="append") + parser.add_argument("--sub-account", required=True, action="append") + parser.add_argument("--template-param", action="append", default=[]) + parser.add_argument("--project") + parser.add_argument("--description") + parser.add_argument("--short-url-config") + if submit: + parser.add_argument("--preview-digest", required=True) + + +def _send_options(parser: argparse.ArgumentParser, *, submit: bool) -> None: + parser.add_argument("--sub-account", required=True) + parser.add_argument("--signature", required=True) + parser.add_argument("--template-id", required=True) + parser.add_argument("--mobile", required=True, action="append") + parser.add_argument("--template-params", required=True) + parser.add_argument( + "--dedupe-policy", choices=("reject", "keep-first"), default="reject" + ) + if submit: + parser.add_argument("--preview-digest", required=True) + parser.add_argument("--authorization-digest", required=True) + + +def _batch_identity_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--sub-account", required=True) + parser.add_argument("--task-id", required=True) + + +def _batch_handoff_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--file-sha256", type=_sha256_value) + parser.add_argument("--total-count", type=_bounded_int("total-count", 0, 1000000)) + parser.add_argument("--valid-count", type=_bounded_int("valid-count", 0, 1000000)) + parser.add_argument( + "--invalid-count", type=_bounded_int("invalid-count", 0, 1000000) + ) + parser.add_argument("--dup-count", type=_bounded_int("dup-count", 0, 1000000)) + + +def build_parser() -> JsonArgumentParser: + parser = JsonArgumentParser(description="Volcengine domestic SMS") + commands = parser.add_subparsers(dest="command", required=True) + groups = commands.add_parser("list-message-groups") + groups.add_argument("--name") + group_detail = commands.add_parser("message-group-detail") + group_detail.add_argument( + "--sub-account", required=True, type=_non_empty_text("sub-account") + ) + qualifications = commands.add_parser("list-qualifications") + qualifications.add_argument("--id", dest="qualification_id", type=int) + qualifications.add_argument("--material-name") + qualifications.add_argument("--status", action="append", type=int) + _page_options(qualifications) + signatures = commands.add_parser("list-signatures") + signatures.add_argument("--signature") + signatures.add_argument("--sub-account", action="append") + _page_options(signatures) + templates = commands.add_parser("list-templates") + templates.add_argument("--template-id") + templates.add_argument("--sub-account", action="append") + templates.add_argument("--signature", action="append") + _page_options(templates) + match_template = commands.add_parser("match-template") + match_template.add_argument("--content", required=True) + match_template.add_argument("--signature", required=True) + match_template.add_argument("--sub-account", required=True) + match_template.add_argument( + "--channel-type", required=True, choices=("CN_OTP", "CN_NTC", "CN_MKT") + ) + analytics = commands.add_parser("analytics") + analytics.add_argument("--start", required=True) + analytics.add_argument("--end", required=True) + analytics.add_argument("--sub-account") + analytics.add_argument("--channel-type", choices=("CN_OTP", "CN_NTC", "CN_MKT")) + analytics.add_argument("--signature") + analytics.add_argument("--template-id") + analytics.add_argument("--mobile") + analytics.add_argument("--bucket", choices=("total", "hour", "day"), default="day") + analytics.add_argument("--include-logs", action="store_true") + analytics.add_argument( + "--dimension", + choices=("subAccount", "signature", "template", "time"), + default="time", + ) + analytics.add_argument( + "--page-size", type=_bounded_int("page-size", 1, 100), default=100 + ) + analytics.add_argument( + "--max-pages", type=_bounded_int("max-pages", 1, 100), default=10 + ) + _signature_application_options( + commands.add_parser("signature-preview"), submit=False + ) + _signature_application_options(commands.add_parser("signature-submit"), submit=True) + _template_application_options(commands.add_parser("template-preview"), submit=False) + _template_application_options(commands.add_parser("template-submit"), submit=True) + _send_options(commands.add_parser("send-preview"), submit=False) + _send_options(commands.add_parser("send-submit"), submit=True) + send_status = commands.add_parser("send-status") + send_status.add_argument("--sub-account", required=True) + send_status.add_argument("--message-id", required=True) + _page_options(send_status) + precheck = commands.add_parser("batch-precheck") + precheck.add_argument("--file", required=True) + precheck.add_argument("--template-param", action="append") + demo = commands.add_parser("batch-template-demo") + demo.add_argument("--sub-account", required=True) + demo.add_argument("--template-id", required=True) + demo.add_argument("--force-update", action="store_true") + create = commands.add_parser("batch-create") + create.add_argument("--file", required=True) + create.add_argument("--sub-account", required=True) + create.add_argument("--task-name", required=True) + create.add_argument("--signature", required=True) + create.add_argument("--template-id", required=True) + create.add_argument("--scheduled", action="store_true") + create.add_argument("--send-time") + detail = commands.add_parser("batch-detail") + _batch_identity_options(detail) + tasks = commands.add_parser("batch-list") + tasks.add_argument("--sub-account", required=True) + tasks.add_argument("--task-name") + tasks.add_argument("--signature") + tasks.add_argument("--template-id") + _page_options(tasks) + launch_preview = commands.add_parser("batch-launch-preview") + _batch_identity_options(launch_preview) + _batch_handoff_options(launch_preview) + launch_submit = commands.add_parser("batch-launch-submit") + _batch_identity_options(launch_submit) + _batch_handoff_options(launch_submit) + launch_submit.add_argument("--preview-digest", required=True) + launch_submit.add_argument("--authorization-text", required=True) + cancel = commands.add_parser("batch-cancel") + _batch_identity_options(cancel) + return parser diff --git a/server/mcp_server_sms/src/mcp_server_sms/inputs.py b/server/mcp_server_sms/src/mcp_server_sms/inputs.py new file mode 100644 index 00000000..e79ea815 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/inputs.py @@ -0,0 +1,147 @@ +"""Owned file exchange without putting document bytes in MCP arguments.""" + +import base64 +import hashlib +import json +import os +from pathlib import PurePosixPath +import stat + +from .core.qualification_upload import _validated_image_bytes +from .store import StoreError + +INPUT_LIMITS = { + "qualification_image": 2 * 1024 * 1024, + "qualification_data": 64 * 1024, + "verification_code": 1024, + "batch_csv": 50 * 1024 * 1024, +} + + +class InputFiles: + def __init__(self, store, ttl, local_root): + self.store, self.ttl, self.local_root = store, ttl, local_root + + def stage(self, owner, kind, content, content_type): + maximum = INPUT_LIMITS.get(kind) + if maximum is None or not content or len(content) > maximum: + raise StoreError("文件类型不支持或文件大小超出限制") + if kind == "qualification_image": + content, content_type = _validated_image_bytes(content, content_type) + elif kind in ("qualification_data", "verification_code"): + try: + value = json.loads(content.decode("utf-8")) + if not isinstance(value, dict): + raise ValueError + if kind == "verification_code" and ( + set(value) != {"code"} + or not isinstance(value["code"], str) + or len(value["code"]) != 4 + or any(c not in "0123456789" for c in value["code"]) + ): + raise ValueError + except (ValueError, UnicodeError): + raise StoreError("需要符合文件类型的 JSON 对象") from None + content_type = "application/json" + else: + try: + content.decode("utf-8-sig") + except UnicodeError: + raise StoreError("CSV 必须使用 UTF-8 编码") from None + content_type = "text/csv" + file_id = self.store.create( + owner, + "file", + { + "kind": kind, + "content": base64.b64encode(content).decode(), + "content_type": content_type, + }, + self.ttl, + ) + return { + "fileId": file_id, + "kind": kind, + "fileSize": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "expiresInSeconds": self.ttl, + } + + def read(self, file_id, owner, kind=None): + record = self.store.get(file_id, owner, "file")["data"] + if kind is not None and record["kind"] != kind: + raise StoreError("文件用途不匹配") + return base64.b64decode(record["content"]), record["content_type"] + + def document(self, file_id, owner, kind="qualification_data"): + content, _ = self.read(file_id, owner, kind) + return json.loads(content) + + def _directory(self, relative_path): + if not self.local_root: + raise StoreError("未配置本地材料目录 SMS_MCP_INPUT_ROOT") + path = PurePosixPath(relative_path) + if path.is_absolute() or not path.parts or any(p == ".." for p in path.parts): + raise StoreError("只能使用材料目录内的相对路径") + descriptor = os.open( + self.local_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + ) + try: + for part in path.parts[:-1]: + child = os.open( + part, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=descriptor, + ) + os.close(descriptor) + descriptor = child + return descriptor, path.name + except BaseException: + os.close(descriptor) + raise + + def import_local(self, relative_path, kind, content_type, owner): + maximum = INPUT_LIMITS.get(kind) + if maximum is None: + raise StoreError("文件类型不支持") + directory = None + try: + directory, name = self._directory(relative_path) + descriptor = os.open( + name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory + ) + with os.fdopen(descriptor, "rb") as source: + info = os.fstat(source.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: + raise StoreError("需要大小符合限制的普通文件") + content = source.read(maximum + 1) + except OSError: + raise StoreError("无法读取材料文件;不允许符号链接或目录外路径") from None + finally: + if directory is not None: + os.close(directory) + return self.stage(owner, kind, content, content_type) + + def export_local(self, file_id, relative_path, owner): + # This destination is explicitly configured by the desktop user. Only + # derived review data can be exported; raw credentials are never files. + content, _ = self.read(file_id, owner, "qualification_data") + directory = None + try: + directory, name = self._directory(relative_path) + descriptor = os.open( + name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=directory, + ) + with os.fdopen(descriptor, "wb") as destination: + destination.write(content) + except OSError: + raise StoreError( + "无法写入核对文件;目标必须位于材料目录内且尚不存在" + ) from None + finally: + if directory is not None: + os.close(directory) + return {"fileId": file_id, "status": "exported_for_customer_review"} diff --git a/server/mcp_server_sms/src/mcp_server_sms/runtime.py b/server/mcp_server_sms/src/mcp_server_sms/runtime.py new file mode 100644 index 00000000..6c661117 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/runtime.py @@ -0,0 +1,606 @@ +"""SMS workflows, frozen previews, owned drafts and mutation guards.""" + +import dataclasses +import hashlib +import json +import os +import pathlib +import tempfile +import time +import uuid +from contextlib import contextmanager + +from .auth import AuthenticationError, Authenticator, sms_credentials +from .core import qualification, workflows +from .core.qualification_upload import ( + QualificationUploadError, + _diagnostic_value, + _diagnostic_replacements, +) +from .core.action_contracts import ACTION_REGISTRY +from .core.api_client import SmsApiClient +from .core.api_protocol import sanitize_output +from .store import Store, StoreError +from .inputs import InputFiles + + +class GuardedClient: + """Deduplicate actual SMS mutations before dispatch, including qualification submission.""" + + def __init__(self, client, store, owner, operation_id, ttl): + self.client, self.store, self.owner = client, store, owner + self.operation_id, self.ttl = operation_id, ttl + self._timeout = client._timeout + + def call(self, action, params, **kwargs): + if ACTION_REGISTRY[action].read_only: + return self.client.call(action, params, **kwargs) + return self.store.once( + self.owner, + self.operation_id + ":" + action, + params, + self.ttl, + lambda: self.client.call(action, params, **kwargs), + ) + + +class Runtime: + def __init__(self, settings, *, store=None): + self.settings = settings + self.store = store or Store(settings.database_url, settings.encryption_key) + self.auth = Authenticator(settings) + self.inputs = InputFiles(self.store, settings.flow_ttl, settings.input_root) + + def caller(self, ctx): + self.store.cleanup() + headers = ctx.headers + if headers is None: + owner = os.environ.get("SMS_MCP_LOCAL_SUBJECT") + if not owner: + raise AuthenticationError("stdio 模式需要 SMS_MCP_LOCAL_SUBJECT") + else: + owner = self.auth.owner(headers) + return owner, sms_credentials(headers) + + @staticmethod + def arguments(command, options): + argv = [command] + for key, value in options.items(): + if value is None or value is False: + continue + flag = "--" + key.replace("_", "-") + if value is True: + argv.append(flag) + else: + for item in value if isinstance(value, list) else [value]: + encoded = ( + json.dumps(item, ensure_ascii=False) + if isinstance(item, dict) + else str(item) + ) + argv.append(flag + "=" + encoded) + + return workflows.build_parser().parse_args(argv) + + def query(self, command, options, owner, credentials): + client = SmsApiClient(credentials) + result = workflows.execute(self.arguments(command, options), client) + return self.safe(result, credentials) + + def template_detail(self, template_id, signature, sub_account, owner, credentials): + detail = workflows._direct_send_template_detail( + SmsApiClient(credentials), template_id, signature, sub_account + ) + content = detail["Content"] + safe_content = self.safe({"content": content}, credentials)["content"] + return { + "templateId": template_id, + "subAccount": sub_account, + "signature": signature, + "channelType": workflows._template_value( + detail, "ChannelType", "channelType" + ), + "content": safe_content, + "contentRedacted": safe_content != content, + "contentSha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), + "variables": workflows._template_param_names(detail), + "status": workflows._template_value(detail, "Status", "status"), + } + + @staticmethod + def safe(result, credentials): + return sanitize_output( + result, secrets=tuple(dataclasses.asdict(credentials).values()) + ) + + def prepare(self, kind, options, owner, credentials): + command = { + "signature": "signature-preview", + "template": "template-preview", + "send": "send-preview", + "batch_launch": "batch-launch-preview", + }[kind] + result = workflows.execute( + self.arguments(command, options), SmsApiClient(credentials) + ) + if not result.get("success"): + return self.safe(result, credentials) + result_data = result["result"] + digest = result_data["digest"] + operation_id = self.store.create( + owner, + "preview", + { + "kind": kind, + "options": options, + "digest": digest, + "credential_fingerprint": self.credential_fingerprint(credentials), + }, + self.settings.flow_ttl, + ) + return { + "operationId": operation_id, + "preview": self.safe(result_data, credentials), + "requiresConfirmation": True, + "expiresInSeconds": self.settings.flow_ttl, + } + + @staticmethod + def credential_fingerprint(credentials): + # A preview remains attached to the exact delegated identity; changing a + # credential requires a new preview instead of sending on another account. + return hashlib.sha256( + json.dumps(dataclasses.asdict(credentials), sort_keys=True).encode() + ).hexdigest() + + def execute(self, operation_id, owner, credentials): + stored_preview = self.store.get(operation_id, owner, "preview") + record = stored_preview["data"] + operation_ttl = max( + self.settings.operation_ttl, + int(stored_preview["expires"] - time.time()) + 1, + ) + if record["credential_fingerprint"] != self.credential_fingerprint(credentials): + raise StoreError("调用凭据已变化,请重新生成预览") + + def run(): + options, kind = dict(record["options"]), record["kind"] + client = GuardedClient( + SmsApiClient(credentials), + self.store, + owner, + operation_id, + operation_ttl, + ) + try: + if kind in ("qualification", "qualification_code"): + return self._execute_qualification( + kind, options, record["digest"], owner, credentials + ) + if kind == "batch_create": + return self._create_batch(options, record["digest"], client, owner) + if kind == "batch_cancel": + result = workflows.execute( + self.arguments("batch-cancel", options), client + ) + else: + options["preview_digest"] = record["digest"] + if kind == "send": + options["authorization_digest"] = record["digest"] + if kind == "batch_launch": + options["authorization_text"] = ( + "确认启动任务 " + options["task_id"] + ) + command = { + "signature": "signature-submit", + "template": "template-submit", + "send": "send-submit", + "batch_launch": "batch-launch-submit", + }[kind] + result = workflows.execute(self.arguments(command, options), client) + return self.safe(result, credentials) + except workflows.CliError as exc: + return self.safe( + { + "success": False, + "error": {"code": exc.code, "message": str(exc)}, + }, + credentials, + ) + + return self.store.once( + owner, "execute:" + operation_id, record, operation_ttl, run + ) + + def operation_result(self, operation_id, owner, credentials): + operation = self.store.operation(owner, "execute:" + operation_id) + if operation is None: + self.store.get(operation_id, owner, "preview") + return {"operationId": operation_id, "status": "not_executed"} + if operation["status"] != "completed": + return { + "operationId": operation_id, + "status": "outcome_unknown", + "outcomeUnknown": True, + } + return self.safe(operation["data"]["result"], credentials) + + def qualification_requirements(self, owner, credentials): + return qualification.requirements( + qualification.new_state(SmsApiClient(credentials)) + ) + + def create_qualification_draft(self, name, purpose, owner, credentials): + client = SmsApiClient(credentials) + state = qualification.new_state(client) + qualification.QualificationDraft(client, state).set_base(name, purpose) + draft_id = self.store.create( + owner, + "qualification", + { + "state": state, + "credential_fingerprint": self.credential_fingerprint(credentials), + }, + self.settings.flow_ttl, + ) + return { + "draftId": draft_id, + "expiresInSeconds": self.settings.flow_ttl, + **qualification.public_state(state), + "requirements": qualification.requirements(state), + } + + def _recover_qualification(self, draft_id, owner, state): + if state["done"]: + return + operation = self.store.operation( + owner, + f"qualification:{draft_id}:submit:{state['revision']}:ApplySignatureIdentificationForAgent", + ) + if operation is None: + return + result = operation["data"].get("result") + if operation["status"] == "completed" and result and result.get("success"): + value = result.get("result") + if type(value) is int and value > 0: + state.update( + done=True, + result={ + "status": "submitted_for_review", + "qualificationId": value, + "requestId": result.get("request_id"), + "revision": state["revision"], + }, + ) + return + if ( + result + and result.get("success") is False + and not (result.get("error") or {}).get("outcome_unknown") + ): + return + state.update( + done=True, + result={ + "status": "qualification_submission_outcome_unknown", + "outcomeUnknown": True, + "requestId": (result or {}).get("request_id"), + }, + ) + + @contextmanager + def _draft(self, draft_id, owner, credentials, *, operation_id=None): + with self.store.locked(draft_id, owner, "qualification") as (record, lease): + data = record["data"] + if data["credential_fingerprint"] != self.credential_fingerprint( + credentials + ): + raise StoreError("调用凭据已变化,请在原授权有效期内处理草稿或重新创建") + state = data["state"] + self._recover_qualification(draft_id, owner, state) + client = SmsApiClient(credentials) + if operation_id: + client = GuardedClient( + client, + self.store, + owner, + operation_id, + max( + self.settings.operation_ttl, + int(record["expires"] - time.time()) + 1, + ), + ) + try: + yield qualification.QualificationDraft(client, state) + finally: + if state["done"]: + data["state"] = {"done": True, "result": state["result"]} + self.store.save(draft_id, owner, data, lease=lease) + + def qualification_status(self, draft_id, owner, credentials): + with self._draft(draft_id, owner, credentials) as draft: + return {"draftId": draft_id, **qualification.public_state(draft.state)} + + def cancel_qualification(self, draft_id, owner, credentials): + with self._draft(draft_id, owner, credentials) as draft: + if not draft.state["done"]: + draft.state.update( + done=True, result={"status": "qualification_application_abandoned"} + ) + return {"draftId": draft_id, **qualification.public_state(draft.state)} + + def change_qualification( + self, draft_id, action, owner, credentials, *, operation_id=None + ): + with self._draft( + draft_id, owner, credentials, operation_id=operation_id + ) as draft: + if draft.state["done"]: + raise StoreError("资质草稿已结束,请查询结果") + try: + detail = action(draft) or {} + return { + "draftId": draft_id, + **qualification.public_state(draft.state), + **detail, + } + except QualificationUploadError as error: + return self._qualification_error(error, draft.state) + except (ValueError, KeyError, TypeError) as error: + message = ( + str(error) + if isinstance(error, ValueError) and str(error) + else "材料字段不完整或格式不正确" + ) + return { + "success": False, + "error": { + "code": "invalid_qualification_input", + "message": message, + }, + } + + @staticmethod + def _qualification_error(error, state): + return { + "success": False, + "error": { + "code": error.code, + "message": _diagnostic_value( + str(error), + field="message", + replacements=_diagnostic_replacements(state), + ), + "requestId": error.request_id, + "logId": error.log_id, + "outcome_unknown": error.outcome_unknown, + }, + } + + def update_qualification_information( + self, draft_id, target, file_id, owner, credentials + ): + document = self.inputs.document(file_id, owner) + return self.change_qualification( + draft_id, + lambda draft: draft.set_information(target, document), + owner, + credentials, + ) + + def upload_qualification_document( + self, draft_id, file_id, options, owner, credentials + ): + content, content_type = self.inputs.read(file_id, owner, "qualification_image") + return self.change_qualification( + draft_id, + lambda draft: draft.upload_document(content, content_type, **options), + owner, + credentials, + ) + + def recognize_qualification_document(self, draft_id, target, owner, credentials): + def recognize(draft): + result = draft.recognize_document(target) + uploaded = self.inputs.stage( + owner, + "qualification_data", + json.dumps(result, ensure_ascii=False).encode(), + "application/json", + ) + return { + "reviewFileId": uploaded["fileId"], + "recognitionStatus": "requires_customer_review", + } + + return self.change_qualification(draft_id, recognize, owner, credentials) + + def prepare_qualification(self, draft_id, role, owner, credentials): + with self._draft(draft_id, owner, credentials) as draft: + if draft.state["done"]: + raise StoreError("资质草稿已结束") + options = {"draft_id": draft_id, "revision": draft.state["revision"]} + if role is None: + preview = draft.preview() + kind = "qualification" + digest = workflows.canonical_digest( + qualification._submission_payload(draft.state) + ) + else: + mobile = draft.code_recipient(role) + options["role"] = role + preview = { + "role": role, + "mobile": mobile, + "action": "send_verification_code", + } + kind = "qualification_code" + digest = workflows.canonical_digest({**options, "mobile": mobile}) + operation_id = self.store.create( + owner, + "preview", + { + "kind": kind, + "options": options, + "digest": digest, + "credential_fingerprint": self.credential_fingerprint(credentials), + }, + self.settings.flow_ttl, + ) + return { + "operationId": operation_id, + "preview": self.safe(_diagnostic_value(preview), credentials), + "requiresConfirmation": True, + "expiresInSeconds": self.settings.flow_ttl, + } + + def _execute_qualification(self, kind, options, digest, owner, credentials): + draft_id, revision = options["draft_id"], options["revision"] + action = "submit" if kind == "qualification" else "code:" + options["role"] + with self._draft( + draft_id, + owner, + credentials, + operation_id=f"qualification:{draft_id}:{action}:{revision}", + ) as draft: + if draft.state["done"]: + if ( + draft.state["result"].get("status") + == "qualification_application_abandoned" + ): + return { + "success": False, + "error": { + "code": "qualification_draft_cancelled", + "message": "资质草稿已取消,不能提交", + }, + } + return { + "success": not draft.state["result"].get("outcomeUnknown", False), + "result": draft.state["result"], + } + try: + if draft.state["revision"] != revision: + raise ValueError("申请信息已变化,请重新生成并确认预览") + if kind == "qualification": + if ( + workflows.canonical_digest( + qualification._submission_payload(draft.state) + ) + != digest + ): + raise ValueError("申请内容与预览不一致") + result = draft.submit(revision) + else: + mobile = draft.code_recipient(options["role"]) + if ( + workflows.canonical_digest({**options, "mobile": mobile}) + != digest + ): + raise ValueError("验证码收件人与预览不一致") + result = draft.send_code(options["role"]) + return {"success": True, "result": result} + except QualificationUploadError as error: + return self._qualification_error(error, draft.state) + except ValueError as error: + return { + "success": False, + "error": { + "code": "stale_qualification_preview", + "message": str(error), + }, + } + + def verify_qualification_code( + self, draft_id, role, file_id, request_id, owner, credentials + ): + uuid.UUID(request_id) + document = self.inputs.document(file_id, owner, "verification_code") + try: + return self.change_qualification( + draft_id, + lambda draft: draft.verify_code(role, document["code"]), + owner, + credentials, + operation_id=f"qualification:{draft_id}:verify:{request_id}", + ) + finally: + self.store.delete_file(file_id, owner) + + def check_batch_file( + self, file_id, sub_account, signature, template_id, owner, credentials + ): + content, _ = self.inputs.read(file_id, owner, "batch_csv") + _, variables = workflows._batch_resources( + SmsApiClient(credentials), sub_account, signature, template_id + ) + return workflows.precheck_batch_csv(content, variables) + + def _batch_preview(self, options, client, owner): + content, _ = self.inputs.read(options["file_id"], owner, "batch_csv") + template, variables = workflows._batch_resources( + client, options["sub_account"], options["signature"], options["template_id"] + ) + report = workflows.precheck_batch_csv(content, variables) + digest = workflows.canonical_digest( + { + "options": options, + "template": template, + "fileSha256": report["fileSha256"], + } + ) + return content, report, digest + + def prepare_batch(self, options, owner, credentials): + _, report, digest = self._batch_preview( + options, SmsApiClient(credentials), owner + ) + operation_id = self.store.create( + owner, + "preview", + { + "kind": "batch_create", + "options": options, + "digest": digest, + "credential_fingerprint": self.credential_fingerprint(credentials), + }, + self.settings.flow_ttl, + ) + return { + "operationId": operation_id, + "preview": {**options, **report}, + "requiresConfirmation": True, + "note": "创建群发任务后还需要单独确认启动", + } + + def _create_batch(self, options, expected_digest, client, owner): + content, _, digest = self._batch_preview(options, client, owner) + if digest != expected_digest: + raise workflows.CliError("文件或模板已变化,请重新预览", "digest_mismatch") + with tempfile.TemporaryDirectory(prefix="sms-mcp-batch-") as directory: + path = pathlib.Path(directory) / "recipients.csv" + path.write_bytes(content) + os.chmod(path, 0o600) + args = {k: v for k, v in options.items() if k != "file_id"} + args["file"] = str(path) + return workflows.execute(self.arguments("batch-create", args), client) + + def prepare_cancel(self, options, owner, credentials): + result = self.query("batch-detail", options, owner, credentials) + if not result.get("success"): + return result + operation_id = self.store.create( + owner, + "preview", + { + "kind": "batch_cancel", + "options": options, + "credential_fingerprint": self.credential_fingerprint(credentials), + }, + self.settings.flow_ttl, + ) + return { + "operationId": operation_id, + "preview": result["result"], + "requiresConfirmation": True, + } diff --git a/server/mcp_server_sms/src/mcp_server_sms/server.py b/server/mcp_server_sms/src/mcp_server_sms/server.py new file mode 100644 index 00000000..829151bb --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/server.py @@ -0,0 +1,607 @@ +"""MCP 2026-07-28 entry point and explicit SMS tool contracts.""" + +import argparse +import asyncio +import json +from typing import Any, Literal + +from mcp.server import MCPServer +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import ToolAnnotations +from pydantic import BaseModel, ConfigDict, Field + +from . import __version__ +from .auth import AuthenticationError +from .config import Settings +from .core.workflows import CliError +from .core.qualification_upload import QualificationUploadError +from .core.qualification import BusinessData, PersonData +from .runtime import Runtime +from .store import StoreError + +Channel = Literal["CN_OTP", "CN_NTC", "CN_MKT"] +READ = ToolAnnotations( + readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True +) +PREPARE = ToolAnnotations( + readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True +) +WRITE = ToolAnnotations( + readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=True +) + + +class RequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class QualificationDocument(RequestModel): + target: Literal[ + "business", + "operator", + "responsible", + "legal", + "powerOfAttorney", + "otherMaterials", + ] + certificate_type: Literal[0, 1, 2, 3, 4, 6, 7, 9] | None = None + side: Literal["front", "back"] | None = None + index: int | None = Field(default=None, ge=0, le=4) + replace: bool = False + + +class SignatureRequest(RequestModel): + content: str + purpose: Literal[1, 2] + qualification_id: int = Field(gt=0) + sub_account: list[str] = Field(min_length=1) + channel_type: list[Channel] = Field(min_length=1) + source: Literal[1, 2, 3] + description: str | None = None + domain: str | None = None + scene: str | None = None + project_name: str | None = None + app_icp: dict | None = None + trademark: dict | None = None + + +class TemplateRequest(RequestModel): + name: str + content: str + channel_type: Channel + signature: list[str] = Field(min_length=1) + sub_account: list[str] = Field(min_length=1) + template_param: list[str] = Field(default_factory=list) + project: str | None = None + description: str | None = None + short_url_config: dict | None = None + + +class SendRequest(RequestModel): + sub_account: str + signature: str + template_id: str + mobile: list[str] = Field(min_length=1, max_length=200) + template_params: dict[str, str] + dedupe_policy: Literal["reject", "keep-first"] = "reject" + + +class BatchRequest(RequestModel): + file_id: str + sub_account: str + task_name: str + signature: str + template_id: str + scheduled: bool = False + send_time: str | None = None + + +class BatchLaunchRequest(RequestModel): + sub_account: str + task_id: str + file_sha256: str + total_count: int = Field(ge=1) + valid_count: int = Field(ge=1) + invalid_count: int = Field(ge=0) + dup_count: int = Field(ge=0) + + +def build_server(runtime: Runtime): + mcp = MCPServer( + "Volcengine SMS", + version=__version__, + instructions=( + "国内短信:先查询消息组,RE:0001 时引导用户在 https://console.volcengine.com/sms 完成开通。" + "资质通过独立工具完成:查询要求、创建草稿、上传材料、确认信息、校验、预览及提交。" + "证件、个人字段、验证码由宿主通过受控文件渠道提供,只向工具传 fileId;不得在聊天中索取原文。" + "HTTP 文件接口与本地受限目录导入是宿主集成渠道,不要求网页。OCR 结果交客户私密核对后再保存。" + "先选择已审核资质、签名和模板。申请及发送必须先 prepare,展示当前预览并取得用户确认," + "再 execute_sms_operation;operationId 仅绑定预览,不代表用户授权,客户端必须实施确认。" + "普通群发使用 batch_csv 文件引用,创建任务后单独确认启动。特殊通知群发不在支持范围内。" + "API 接受、任务完成和短信送达分别判断。outcome_unknown 不得自动重发。" + "申请提交后查询审核状态,审核通过后才继续后续任务。工具不能代替客户确认或同意服务协议。" + ), + ) + + async def invoke(ctx, method, *args): + def run(): + owner, credentials = runtime.caller(ctx) + result = method(*args, owner, credentials) + return runtime.safe(result, credentials) + + try: + result = await asyncio.to_thread(run) + except ( + CliError, + StoreError, + AuthenticationError, + QualificationUploadError, + ValueError, + ) as exc: + raise ToolError(str(exc)) from None + if result.get("success") is False: + raise ToolError(json.dumps(result, ensure_ascii=False)) + return result + + async def query(ctx, command, **options): + return await invoke(ctx, runtime.query, command, options) + + @mcp.tool(annotations=READ, structured_output=True) + async def list_message_groups( + ctx: Context, name: str | None = None + ) -> dict[str, Any]: + """查询当前调用者的消息组;只有 RE:0001 表示未开通短信服务。""" + return await query(ctx, "list-message-groups", name=name) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_message_group(ctx: Context, sub_account: str) -> dict[str, Any]: + """查询指定消息组状态及允许发送的短信类型。""" + return await query(ctx, "message-group-detail", sub_account=sub_account) + + @mcp.tool(annotations=READ, structured_output=True) + async def list_qualifications( + ctx: Context, + qualification_id: int | None = None, + material_name: str | None = None, + status: list[int] | None = None, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """查询资质及审核结果,仅返回客户可见的安全字段。""" + return await query( + ctx, + "list-qualifications", + id=qualification_id, + material_name=material_name, + status=status, + page=page, + page_size=page_size, + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def list_signatures( + ctx: Context, + signature: str | None = None, + sub_account: list[str] | None = None, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """查询短信签名和审核状态。""" + return await query( + ctx, + "list-signatures", + signature=signature, + sub_account=sub_account, + page=page, + page_size=page_size, + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def list_templates( + ctx: Context, + template_id: str | None = None, + sub_account: list[str] | None = None, + signature: list[str] | None = None, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """查询模板、变量和审核状态。""" + return await query( + ctx, + "list-templates", + template_id=template_id, + sub_account=sub_account, + signature=signature, + page=page, + page_size=page_size, + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_template( + ctx: Context, template_id: str, signature: str, sub_account: str + ) -> dict[str, Any]: + """读取指定签名、消息组下模板的正文和变量,正文不含签名前缀。contentRedacted=true 表示正文已脱敏或截断,不能据此声称获得完整原文。实际发送前仍需 prepare_send 校验资源和生成预览;消息组行业映射为空不构成单条发送的独立门禁。""" + return await invoke( + ctx, runtime.template_detail, template_id, signature, sub_account + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def match_template( + ctx: Context, + content: str, + signature: str, + sub_account: str, + channel_type: Channel, + ) -> dict[str, Any]: + """按完整内容、签名、消息组及短信类型精确匹配已审核模板。""" + return await query( + ctx, + "match-template", + content=content, + signature=signature, + sub_account=sub_account, + channel_type=channel_type, + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_signature( + ctx: Context, request: SignatureRequest + ) -> dict[str, Any]: + """校验并预览签名申请。展示预览并取得客户确认后才能执行 operationId。""" + return await invoke(ctx, runtime.prepare, "signature", request.model_dump()) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_template( + ctx: Context, request: TemplateRequest + ) -> dict[str, Any]: + """校验并预览模板申请。展示预览并取得客户确认后才能执行 operationId。""" + return await invoke(ctx, runtime.prepare, "template", request.model_dump()) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_send(ctx: Context, request: SendRequest) -> dict[str, Any]: + """重新校验资源与精确变量,生成脱敏发送预览。此步骤不发送短信。""" + return await invoke(ctx, runtime.prepare, "send", request.model_dump()) + + @mcp.tool(annotations=WRITE, structured_output=True) + async def execute_sms_operation(ctx: Context, operation_id: str) -> dict[str, Any]: + """执行已预览且用户已明确确认的短信操作,可能产生费用。客户端必须确认相同预览;禁止自动确认、修改字段或重试未知结果。""" + return await invoke(ctx, runtime.execute, operation_id) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_sms_operation_result( + ctx: Context, operation_id: str + ) -> dict[str, Any]: + """只读查询执行记录,不会重发。连接中断后优先查询此记录。""" + return await invoke(ctx, runtime.operation_result, operation_id) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_send_status( + ctx: Context, + sub_account: str, + message_id: str, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """通过 Message ID 查询公开发送日志和回执;提交成功不等于送达。""" + return await query( + ctx, + "send-status", + sub_account=sub_account, + message_id=message_id, + page=page, + page_size=page_size, + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def analyze_delivery( + ctx: Context, + start: str, + end: str, + sub_account: str | None = None, + channel_type: Channel | None = None, + signature: str | None = None, + template_id: str | None = None, + mobile: str | None = None, + bucket: Literal["total", "hour", "day"] = "day", + include_logs: bool = False, + dimension: Literal["subAccount", "signature", "template", "time"] = "time", + page_size: int = 100, + max_pages: int = 10, + ) -> dict[str, Any]: + """按明确时间范围分析提交和送达情况。日志需要显式消息组,输出说明统计口径与截断情况。""" + return await query( + ctx, + "analytics", + start=start, + end=end, + sub_account=sub_account, + channel_type=channel_type, + signature=signature, + template_id=template_id, + mobile=mobile, + bucket=bucket, + include_logs=include_logs, + dimension=dimension, + page_size=page_size, + max_pages=max_pages, + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_qualification_requirements(ctx: Context) -> dict[str, Any]: + """查询当前账号的资质材料要求和私密 JSON 文件字段规范,不创建申请。""" + result = await invoke(ctx, runtime.qualification_requirements) + result["privateInputSchemas"] = { + "business": BusinessData.model_json_schema(), + "person": PersonData.model_json_schema(), + } + return result + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def create_qualification_draft( + ctx: Context, name: str, purpose: Literal[1, 2] + ) -> dict[str, Any]: + """创建资质草稿:name 为 1 至 20 字,purpose 1 自用、2 他用;不提交线上申请,不依赖网页。草稿有效期以返回值为准。""" + return await invoke(ctx, runtime.create_qualification_draft, name, purpose) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def update_qualification_base( + ctx: Context, draft_id: str, name: str, purpose: Literal[1, 2] + ) -> dict[str, Any]: + """修改资质名称或自用/他用用途,使原申请预览失效。""" + return await invoke( + ctx, + runtime.change_qualification, + draft_id, + lambda draft: draft.set_base(name, purpose), + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def import_input_file( + ctx: Context, + relative_path: str, + kind: Literal[ + "qualification_image", + "qualification_data", + "verification_code", + "batch_csv", + ], + content_type: str, + ) -> dict[str, Any]: + """仅限 stdio:导入客户明确提供且已放入配置目录的文件,返回 fileId。禁止读取原文进入聊天。图片 JPEG/PNG,单张不超过 2 MB;CSV 不超过 50 MB。HTTP 客户端应由宿主通过鉴权的 POST /files 上传。""" + if ctx.headers is not None: + raise ToolError("HTTP 模式不允许读取服务器本地目录;请使用鉴权文件上传接口") + try: + owner, _credentials = runtime.caller(ctx) + return await asyncio.to_thread( + runtime.inputs.import_local, relative_path, kind, content_type, owner + ) + except (StoreError, QualificationUploadError, AuthenticationError) as error: + raise ToolError(str(error)) from None + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def export_qualification_review( + ctx: Context, file_id: str, relative_path: str + ) -> dict[str, Any]: + """仅限 stdio:将 OCR 核对数据导出到材料目录内的全新文件,交客户私密查看。禁止把导出内容读入聊天。HTTP 宿主使用鉴权的 GET /files/{fileId}。""" + if ctx.headers is not None: + raise ToolError("HTTP 宿主请通过鉴权文件接口获取私密核对数据") + try: + owner, _credentials = runtime.caller(ctx) + return await asyncio.to_thread( + runtime.inputs.export_local, file_id, relative_path, owner + ) + except (StoreError, AuthenticationError) as error: + raise ToolError(str(error)) from None + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def upload_qualification_document( + ctx: Context, draft_id: str, file_id: str, document: QualificationDocument + ) -> dict[str, Any]: + """将 qualification_image 文件上传到短信材料服务并关联草稿。营业证件指定类型;居民身份证指定类型 0 和 front/back;其他附件指定从 0 开始的连续 index。变更材料后重新确认和校验,不提交资质。""" + return await invoke( + ctx, + runtime.upload_qualification_document, + draft_id, + file_id, + document.model_dump(), + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def recognize_qualification_document( + ctx: Context, + draft_id: str, + target: Literal["business", "operator", "responsible", "legal"], + ) -> dict[str, Any]: + """识别已上传的营业证件或居民身份证正反面。返回私密核对文件 ID;OCR 不自动替客户确认或提交。""" + return await invoke( + ctx, runtime.recognize_qualification_document, draft_id, target + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def update_qualification_information( + ctx: Context, + draft_id: str, + target: Literal["business", "operator", "responsible", "legal"], + file_id: str, + ) -> dict[str, Any]: + """保存客户已核对的 qualification_data JSON 文件,字段由 get_qualification_requirements 描述。修改后旧校验和预览失效,不在参数中接收原始个人字段。""" + return await invoke( + ctx, runtime.update_qualification_information, draft_id, target, file_id + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def set_qualification_responsible( + ctx: Context, draft_id: str, same_operator: bool + ) -> dict[str, Any]: + """设置责任人是否与经办人相同;相同时复用已保存的经办人材料和校验。""" + return await invoke( + ctx, + runtime.change_qualification, + draft_id, + lambda draft: draft.set_responsible_mode(same_operator), + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def clear_qualification_legal(ctx: Context, draft_id: str) -> dict[str, Any]: + """移除可选的法人补充材料;营业证件上的法人姓名仍保留。""" + return await invoke( + ctx, + runtime.change_qualification, + draft_id, + lambda draft: draft.clear_optional_legal(), + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def check_qualification_information( + ctx: Context, + draft_id: str, + target: Literal["business", "operator", "responsible", "legal"], + ) -> dict[str, Any]: + """校验已保存的企业或人员信息,只返回脱敏状态;内部票据不暴露给模型。""" + return await invoke( + ctx, + runtime.change_qualification, + draft_id, + lambda draft: draft.check_information(target), + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_qualification_verification_code( + ctx: Context, draft_id: str, role: Literal["operator", "responsible"] + ) -> dict[str, Any]: + """预览向指定人员手机号发送验证码;展示脱敏号码并取得客户确认后使用 execute_sms_operation。""" + return await invoke(ctx, runtime.prepare_qualification, draft_id, role) + + @mcp.tool(annotations=WRITE, structured_output=True) + async def verify_qualification_code( + ctx: Context, + draft_id: str, + role: Literal["operator", "responsible"], + file_id: str, + request_id: str, + ) -> dict[str, Any]: + """验证客户通过私密渠道提供的 verification_code JSON 文件。request_id 为本次操作 UUID;服务端暂存副本在验证调用后删除,验证码不回显。""" + return await invoke( + ctx, runtime.verify_qualification_code, draft_id, role, file_id, request_id + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_qualification_application( + ctx: Context, draft_id: str + ) -> dict[str, Any]: + """在材料和必要校验齐备后生成资质申请预览;客户确认当前预览后使用 execute_sms_operation 提交。""" + return await invoke(ctx, runtime.prepare_qualification, draft_id, None) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_qualification_draft(ctx: Context, draft_id: str) -> dict[str, Any]: + """查询草稿缺少的材料、校验进展或提交结果;申请成功后用 list_qualifications 查询审核。""" + return await invoke(ctx, runtime.qualification_status, draft_id) + + @mcp.tool(annotations=WRITE, structured_output=True) + async def cancel_qualification_draft(ctx: Context, draft_id: str) -> dict[str, Any]: + """放弃尚未提交的草稿并清除草稿材料,不撤销已提交或结果未知的申请。""" + return await invoke(ctx, runtime.cancel_qualification, draft_id) + + @mcp.tool(annotations=READ, structured_output=True) + async def check_batch_file( + ctx: Context, file_id: str, sub_account: str, signature: str, template_id: str + ) -> dict[str, Any]: + """校验 batch_csv 文件的号码、重复项及选定模板变量,返回统计与文件指纹,不创建或启动群发任务。""" + return await invoke( + ctx, runtime.check_batch_file, file_id, sub_account, signature, template_id + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_batch_csv_template( + ctx: Context, sub_account: str, template_id: str + ) -> dict[str, Any]: + """读取选定普通群发模板要求的 CSV 表头和示例。""" + return await query( + ctx, + "batch-template-demo", + sub_account=sub_account, + template_id=template_id, + force_update=False, + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_batch_task(ctx: Context, request: BatchRequest) -> dict[str, Any]: + """校验已上传的 CSV 和已审核的普通通知/营销模板,预览群发任务;不支持验证码群发。""" + return await invoke(ctx, runtime.prepare_batch, request.model_dump()) + + @mcp.tool(annotations=READ, structured_output=True) + async def get_batch_task( + ctx: Context, sub_account: str, task_id: str + ) -> dict[str, Any]: + """查询普通群发任务。处理完成不等于每个号码都已送达。""" + return await query( + ctx, "batch-detail", sub_account=sub_account, task_id=task_id + ) + + @mcp.tool(annotations=READ, structured_output=True) + async def list_batch_tasks( + ctx: Context, + sub_account: str, + task_name: str | None = None, + signature: str | None = None, + template_id: str | None = None, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """查询普通群发任务列表;已知 taskId 时优先查询详情。""" + return await query( + ctx, + "batch-list", + sub_account=sub_account, + task_name=task_name, + signature=signature, + template_id=template_id, + page=page, + page_size=page_size, + ) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_batch_launch( + ctx: Context, request: BatchLaunchRequest + ) -> dict[str, Any]: + """生成普通群发启动预览。文件与内容摘要、人数取自创建结果。需客户明确确认启动该任务。""" + return await invoke(ctx, runtime.prepare, "batch_launch", request.model_dump()) + + @mcp.tool(annotations=PREPARE, structured_output=True) + async def prepare_batch_cancel( + ctx: Context, sub_account: str, task_id: str + ) -> dict[str, Any]: + """读取最新任务并预览取消操作;执行时再次校验是否仍可取消。""" + return await invoke( + ctx, + runtime.prepare_cancel, + {"sub_account": sub_account, "task_id": task_id}, + ) + + return mcp + + +def main(): + parser = argparse.ArgumentParser(description="Volcengine SMS MCP server") + parser.add_argument( + "-t", "--transport", choices=["stdio", "streamable-http"], default="stdio" + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + settings = Settings.from_env() + runtime = Runtime(settings) + mcp = build_server(runtime) + if args.transport == "stdio": + mcp.run(transport="stdio") + else: + import uvicorn + from .web import create_app + + settings.validate_http() + uvicorn.run(create_app(runtime, mcp), host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/server/mcp_server_sms/src/mcp_server_sms/store.py b/server/mcp_server_sms/src/mcp_server_sms/store.py new file mode 100644 index 00000000..8761fc37 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/store.py @@ -0,0 +1,253 @@ +"""Encrypted shared drafts and durable mutation deduplication. + +PostgreSQL is supported for replicas. SQLite is useful for a single local process. +No credentials or form fields are stored in plaintext. +""" + +import hashlib +import json +import secrets +import threading +import time +import uuid +from contextlib import contextmanager + +from cryptography.fernet import Fernet +from sqlalchemy import ( + Column, + Float, + Integer, + MetaData, + String, + Table, + Text, + create_engine, + delete, + insert, + select, + update, +) +from sqlalchemy.exc import IntegrityError + + +class StoreError(ValueError): + pass + + +metadata = MetaData() +records = Table( + "sms_mcp_records", + metadata, + Column("id", String(64), primary_key=True), + Column("owner", String(64), nullable=False), + Column("kind", String(32), nullable=False), + Column("status", String(32), nullable=False), + Column("data", Text, nullable=False), + Column("expires", Float, nullable=False, index=True), + Column("revision", Integer, nullable=False, default=0), + Column("lease", String(64)), + Column("lease_until", Float, nullable=False, default=0), +) + + +class Store: + def __init__(self, database_url: str, encryption_key: str): + self.engine = create_engine(database_url, pool_pre_ping=True) + self.cipher = Fernet(encryption_key.encode()) + metadata.create_all(self.engine) + + def _encode(self, value): + return self.cipher.encrypt( + json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode() + ).decode() + + def _decode(self, value): + return json.loads(self.cipher.decrypt(value.encode())) + + @staticmethod + def _owner(owner): + return hashlib.sha256(owner.encode()).hexdigest() + + def create(self, owner, kind, data, ttl, *, status="ready", record_id=None): + record_id = record_id or uuid.uuid4().hex + with self.engine.begin() as connection: + connection.execute( + insert(records).values( + id=record_id, + owner=self._owner(owner), + kind=kind, + status=status, + data=self._encode(data), + expires=time.time() + ttl, + revision=0, + lease_until=0, + ) + ) + return record_id + + def get(self, record_id, owner, kind): + with self.engine.connect() as connection: + row = ( + connection.execute( + select(records).where( + records.c.id == record_id, + records.c.owner == self._owner(owner), + records.c.kind == kind, + records.c.expires > time.time(), + ) + ) + .mappings() + .first() + ) + if row is None: + raise StoreError("记录不存在、已过期或不属于当前用户") + result = dict(row) + result["data"] = self._decode(row["data"]) + return result + + def save(self, record_id, owner, data, *, lease, status="ready"): + with self.engine.begin() as connection: + result = connection.execute( + update(records) + .where( + records.c.id == record_id, + records.c.owner == self._owner(owner), + records.c.lease == lease, + records.c.expires > time.time(), + records.c.lease_until > time.time(), + ) + .values( + data=self._encode(data), + status=status, + revision=records.c.revision + 1, + ) + ) + if result.rowcount != 1: + raise StoreError("流程已过期或执行权已变化,请查询最新状态") + + @contextmanager + def locked(self, record_id, owner, kind): + token, stop = secrets.token_hex(16), threading.Event() + with self.engine.begin() as connection: + result = connection.execute( + update(records) + .where( + records.c.id == record_id, + records.c.owner == self._owner(owner), + records.c.kind == kind, + records.c.expires > time.time(), + records.c.lease_until < time.time(), + ) + .values(lease=token, lease_until=time.time() + 120) + ) + if result.rowcount != 1: + raise StoreError("流程不可用或正在处理另一项操作,请稍后查询") + + def renew(): + while not stop.wait(30): + try: + with self.engine.begin() as connection: + result = connection.execute( + update(records) + .where( + records.c.id == record_id, + records.c.lease == token, + ) + .values(lease_until=time.time() + 120) + ) + if result.rowcount != 1: + return + except Exception: + return # save() rejects a lost lease; mutations have a separate durable guard. + + worker = threading.Thread(target=renew, daemon=True) + worker.start() + try: + yield self.get(record_id, owner, kind), token + finally: + stop.set() + worker.join(timeout=2) + with self.engine.begin() as connection: + connection.execute( + update(records) + .where(records.c.id == record_id, records.c.lease == token) + .values(lease=None, lease_until=0) + ) + + def once(self, owner, operation_id, payload, ttl, call): + digest = hashlib.sha256( + json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() + ).hexdigest() + record_id = uuid.uuid5(uuid.NAMESPACE_URL, owner + ":" + operation_id).hex + try: + self.create( + owner, + "mutation", + {"digest": digest}, + ttl, + status="executing", + record_id=record_id, + ) + except IntegrityError: + previous = self.get(record_id, owner, "mutation") + if previous["data"]["digest"] != digest: + raise StoreError("同一次操作的内容发生变化") + if previous["status"] == "completed": + return previous["data"]["result"] + return { + "success": False, + "error": { + "code": "outcome_unknown", + "outcome_unknown": True, + "message": "该请求已开始处理,请查询结果,不要重复提交", + }, + } + try: + result = call() + except Exception: + result = { + "success": False, + "error": { + "code": "outcome_unknown", + "outcome_unknown": True, + "message": "请求结果暂时无法确认,请查询结果,不要重复提交", + }, + } + with self.engine.begin() as connection: + connection.execute( + update(records) + .where(records.c.id == record_id) + .values( + status="completed", + data=self._encode({"digest": digest, "result": result}), + ) + ) + return result + + def operation(self, owner, operation_id): + record_id = uuid.uuid5(uuid.NAMESPACE_URL, owner + ":" + operation_id).hex + try: + return self.get(record_id, owner, "mutation") + except StoreError: + return None + + def cleanup(self): + with self.engine.begin() as connection: + connection.execute( + delete(records).where( + records.c.expires <= time.time(), + records.c.lease_until < time.time(), + ) + ) + + def delete_file(self, file_id, owner): + with self.engine.begin() as connection: + connection.execute( + delete(records).where( + records.c.id == file_id, + records.c.owner == self._owner(owner), + records.c.kind == "file", + ) + ) diff --git a/server/mcp_server_sms/src/mcp_server_sms/web.py b/server/mcp_server_sms/src/mcp_server_sms/web.py new file mode 100644 index 00000000..cc1e6af0 --- /dev/null +++ b/server/mcp_server_sms/src/mcp_server_sms/web.py @@ -0,0 +1,154 @@ +"""Stateless MCP transport and authenticated file exchange; no user interface.""" + +import asyncio +import contextlib +from urllib.parse import urlsplit + +from mcp.server.transport_security import TransportSecuritySettings +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Mount, Route + +from .auth import AuthenticationError +from .core.qualification_upload import QualificationUploadError +from .inputs import INPUT_LIMITS +from .store import StoreError + + +def create_app(runtime, mcp): + settings = runtime.settings + settings.validate_http() + public_url = settings.public_url.rstrip("/") + + async def metadata(request): + return JSONResponse( + { + "resource": public_url + "/mcp", + "authorization_servers": [settings.oidc_issuer], + "bearer_methods_supported": ["header"], + } + ) + + async def upload_file(request): + kind = request.query_params.get("kind") + maximum = INPUT_LIMITS.get(kind) + if maximum is None: + return JSONResponse({"error": "unsupported_file_kind"}, status_code=400) + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > maximum: + return JSONResponse({"error": "file_too_large"}, status_code=413) + content_type = request.headers.get("content-type", "").split(";", 1)[0] + result = await asyncio.to_thread( + runtime.inputs.stage, request.state.owner, kind, bytes(body), content_type + ) + return JSONResponse(result, status_code=201) + + async def read_file(request): + # A trusted host uses this endpoint for customer review, outside model + # context. No MCP tool returns these private bytes or upload credentials. + content, content_type = await asyncio.to_thread( + runtime.inputs.read, request.path_params["file_id"], request.state.owner + ) + return Response(content, media_type=content_type) + + async def health(request): + return JSONResponse({"status": "ok"}) + + async def handled_error(request, error): + message = ( + "材料文件处理失败" + if isinstance(error, QualificationUploadError) + else str(error) + ) + return JSONResponse({"success": False, "message": message}, status_code=409) + + mcp_app = mcp.streamable_http_app( + stateless_http=True, + json_response=True, + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[urlsplit(public_url).netloc], + allowed_origins=[public_url], + ), + ) + + @contextlib.asynccontextmanager + async def lifespan(app): + async def cleanup(): + while True: + await asyncio.sleep(60) + try: + await asyncio.to_thread(runtime.store.cleanup) + except Exception: + import logging + + logging.getLogger(__name__).warning("Expired record cleanup failed") + + async with mcp_app.router.lifespan_context(mcp_app): + task = asyncio.create_task(cleanup()) + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + app = Starlette( + routes=[ + Route("/health", health), + Route("/.well-known/oauth-protected-resource", metadata), + Route("/.well-known/oauth-protected-resource/mcp", metadata), + Route("/files", upload_file, methods=["POST"]), + Route("/files/{file_id}", read_file), + Mount("/", app=mcp_app), + ], + lifespan=lifespan, + exception_handlers={ + StoreError: handled_error, + QualificationUploadError: handled_error, + }, + ) + + class Boundary: + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await app(scope, receive, send) + request = Request(scope) + if scope["path"].rstrip("/") in ("/mcp", "/files") or scope[ + "path" + ].startswith("/files/"): + try: + request.state.owner = await asyncio.to_thread( + runtime.auth.owner, request.headers + ) + except AuthenticationError: + return await JSONResponse( + {"error": "unauthorized"}, + status_code=401, + headers={ + "WWW-Authenticate": f'Bearer resource_metadata="{public_url}/.well-known/oauth-protected-resource/mcp"' + }, + )(scope, receive, send) + + async def secured_send(message): + if message["type"] == "http.response.start": + message = { + **message, + "headers": [ + *message.get("headers", []), + (b"cache-control", b"no-store"), + (b"x-content-type-options", b"nosniff"), + ( + b"content-security-policy", + b"default-src 'none'; frame-ancestors 'none'", + ), + ], + } + await send(message) + + return await app(scope, receive, secured_send) + + return Boundary() diff --git a/server/mcp_server_sms/tests/conftest.py b/server/mcp_server_sms/tests/conftest.py new file mode 100644 index 00000000..119a8336 --- /dev/null +++ b/server/mcp_server_sms/tests/conftest.py @@ -0,0 +1,141 @@ +import pytest +from cryptography.fernet import Fernet + +from mcp_server_sms.auth import subject_identity +from mcp_server_sms.config import Settings +from mcp_server_sms.core.api_protocol import ResolvedCredentials +from mcp_server_sms.runtime import Runtime + + +@pytest.fixture +def settings(tmp_path): + return Settings( + database_url="sqlite:///" + str(tmp_path / "state.db"), + encryption_key=Fernet.generate_key().decode(), + public_url="https://testserver", + oidc_issuer="https://issuer.example", + token_audience="sms-mcp", + input_root=str(tmp_path), + ) + + +@pytest.fixture +def runtime(settings): + return Runtime(settings) + + +@pytest.fixture +def owner(): + return subject_identity("https://issuer.example", "caller-one") + + +@pytest.fixture +def credentials(): + return ResolvedCredentials( + "fixture-access-key", "fixture-secret-key", "fixture-session-token" + ) + + +class FakeSmsClient: + def __init__(self): + self._timeout = 1 + self.calls = [] + self.responses = {} + + def call(self, action, params, **kwargs): + self.calls.append((action, params)) + result = self.responses.get(action) + if callable(result): + return result(params) + if result is not None: + return result + defaults = { + "GetAccountIdentRankForAgent": { + key: False + for key in [ + "isOnlyTwoElement", + "isSkipOtherUse", + "needOtherUseCheck", + "operatorThreeElement", + "needOperatorImage", + "needOperatorMobile", + "responsibleThreeElement", + "needResponsibleImage", + "needResponsibleMobile", + "needBusinessCertificateImage", + ] + }, + "ListAllSmsProduct": {"businessName": "测试企业", "userType": "enterprise"}, + "ListSubAccountForAgent": { + "List": [ + { + "SubAccount": "group-one", + "SubAccountName": "测试消息组", + "Status": 1, + } + ] + }, + "GetSignatureIdentificationList": { + "List": [{"id": 11, "purpose": 1, "auditStatus": 3, "usable": True}] + }, + "ApplySignatureIdentificationForAgent": 123, + "ApplySmsSignatureV2": {"applyId": 100, "status": 1}, + } + return { + "success": True, + "request_id": "fixture-request", + "result": defaults.get(action, {}), + "error": None, + } + + +@pytest.fixture +def sms(monkeypatch): + from mcp_server_sms import runtime as runtime_module + + client = FakeSmsClient() + monkeypatch.setattr(runtime_module, "SmsApiClient", lambda credentials: client) + return client + + +@pytest.fixture +def ready_flow(runtime, owner, credentials, sms): + opened = runtime.create_qualification_draft("测试资质", 1, owner, credentials) + flow_id = opened["draftId"] + with runtime.store.locked(flow_id, owner, "qualification") as (record, lease): + state = record["data"]["state"] + state.update( + revision=7, + materialName="测试资质", + purpose=1, + sameOperator=True, + legalAcknowledged=True, + baseSaved=True, + ) + state["sections"] = { + "business": { + "businessCertificateType": 1, + "businessCertificateName": "测试企业", + "unifiedSocialCreditIdentifier": "911101080000000000", + "legalPersonName": "测试法人", + "businessCertificateValidityPeriodStart": "2020-01-01", + "businessCertificateValidityPeriodEnd": "2099-12-31", + }, + "operator": { + "certificateType": 0, + "personName": "测试经办人", + "personIDCard": "110101199001010000", + "personMobile": "", + }, + } + state["checks"] = { + target: { + "matched": True, + "canContinue": True, + "status": "0", + "ticket": "fixture-ticket-" + target, + } + for target in ["business", "operator"] + } + runtime.store.save(flow_id, owner, record["data"], lease=lease) + return flow_id diff --git a/server/mcp_server_sms/tests/desktop_launcher.py b/server/mcp_server_sms/tests/desktop_launcher.py new file mode 100644 index 00000000..6ae61513 --- /dev/null +++ b/server/mcp_server_sms/tests/desktop_launcher.py @@ -0,0 +1,63 @@ +"""Local desktop acceptance launcher; never shipped in the SMS wheel. + +Run this script with the Python environment of the exact wheel under test. +The official master Skill resolves the account's existing login in-process. +Desktop configuration contains paths only, never cloud credentials. State is +temporary and removed when the server exits, so this launcher is for acceptance +sessions, not a persistent deployment. +""" + +import argparse +import contextlib +import importlib +import io +import os +import pathlib +import sys +import tempfile + +from cryptography.fernet import Fernet + +from mcp_server_sms.server import main as server_main + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--skill-dir", required=True) + args = parser.parse_args() + sys.path.insert(0, str(pathlib.Path(args.skill_dir) / "scripts")) + try: + # A stdio host reserves stdout for MCP messages. Do not expose auth + # diagnostics or credentials through either stream during resolution. + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + skill_api = importlib.import_module("api_client") + credentials = skill_api.SmsApiClient()._credential_resolver.resolve() + except Exception as error: + print( + "SMS desktop acceptance authentication failed: " + type(error).__name__, + file=sys.stderr, + ) + return 1 + + with tempfile.TemporaryDirectory(prefix="sms-mcp-desktop-") as directory: + os.environ.update( + { + "SMS_MCP_DATABASE_URL": "sqlite:///" + + str(pathlib.Path(directory) / "state.db"), + "SMS_MCP_ENCRYPTION_KEY": Fernet.generate_key().decode(), + "SMS_MCP_LOCAL_SUBJECT": "desktop-acceptance", + "VOLCENGINE_ACCESS_KEY": credentials.access_key, + "VOLCENGINE_SECRET_KEY": credentials.secret_key, + "VOLCENGINE_SESSION_TOKEN": credentials.session_token, + } + ) + sys.argv = ["mcp-server-sms", "--transport", "stdio"] + server_main() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/mcp_server_sms/tests/find_send_candidate.py b/server/mcp_server_sms/tests/find_send_candidate.py new file mode 100644 index 00000000..6b5e8314 --- /dev/null +++ b/server/mcp_server_sms/tests/find_send_candidate.py @@ -0,0 +1,353 @@ +"""Find recently delivered SMS resource combinations via read-only MCP calls. + +Runs an explicitly selected wheel and the master Skill's supported credential +resolver. It never invokes preparation, sending, or resource mutation tools. +Credentials remain in memory; output contains only safe resource metadata. +""" + +import argparse +import asyncio +import contextlib +import datetime +import importlib +import io +import json +import shutil +import sys +import tempfile +from pathlib import Path + +from cryptography.fernet import Fernet +from live_readonly import tool_payload +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +READ_TOOLS = frozenset( + { + "list_message_groups", + "analyze_delivery", + "get_message_group", + "list_signatures", + "list_templates", + "get_template", + } +) + + +def rows(payload): + value = payload.get("result", payload) + if isinstance(value, list): + return value + if isinstance(value, dict): + for key in ("List", "list", "Items", "items"): + if isinstance(value.get(key), list): + return value[key] + return [] + + +async def discover(session, args): + queries = 0 + + async def call(name, arguments): + nonlocal queries + if name not in READ_TOOLS: + raise ValueError("This discovery client permits read-only tools only") + cost = 1 + ( + arguments.get("max_pages", 0) if arguments.get("include_logs") else 0 + ) + if queries + cost > args.max_queries: + raise RuntimeError("Read-only query budget exhausted") + queries += cost + result = await session.call_tool(name, arguments) + if result.is_error: + # Tool errors are already filtered by the SMS server. Do not dump + # exception objects, the process environment or credentials. + raise RuntimeError( + "MCP query failed: " + + " ".join(getattr(item, "text", "") for item in result.content) + ) + await asyncio.sleep(0.3) + return tool_payload(result) + + if args.queries: + for query in args.queries: + print( + json.dumps( + { + "stage": "query", + "tool": query["tool"], + "data": await call(query["tool"], query["arguments"]), + }, + ensure_ascii=False, + ), + flush=True, + ) + return + if args.query: + print( + json.dumps( + { + "stage": "query", + "tool": args.query, + "data": await call(args.query, args.arguments), + }, + ensure_ascii=False, + ), + flush=True, + ) + return + groups = rows(await call("list_message_groups", {})) + end = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).replace( + microsecond=0 + ) + window = { + "start": (end - datetime.timedelta(days=args.days)).isoformat(), + "end": end.isoformat(), + "bucket": "total", + } + print( + json.dumps( + {"stage": "discovery", "groupCount": len(groups), "window": window}, + ensure_ascii=False, + ), + flush=True, + ) + excluded = set(args.exclude_group) + # Interleave recent and older groups instead of assuming creation order is + # evidence of active traffic. Delivery records determine eligibility. + ordered = [] + for index in range((len(groups) + 1) // 2): + ordered.append(groups[index]) + opposite = len(groups) - index - 1 + if opposite != index: + ordered.append(groups[opposite]) + scanned = 0 + candidates = [] + seen = set() + for group in ordered: + group_id = group.get("SubAccount") + if not group_id or group_id in excluded: + continue + if queries + 12 > args.max_queries: + break + report = await call("analyze_delivery", {**window, "sub_account": group_id}) + data = report.get("result", report) + metrics = data["trend"][0]["metrics"] + delivered = metrics["receipts"]["success"] + submitted = metrics["submission_success_rate"]["numerator"] + scanned += 1 + if scanned % 10 == 0 or delivered: + print( + json.dumps( + { + "stage": "group_stats", + "scanned": scanned, + "queries": queries, + "groupId": group_id, + "groupName": group.get("SubAccountName"), + "submitted": submitted, + "delivered": delivered, + }, + ensure_ascii=False, + ), + flush=True, + ) + if not isinstance(delivered, int) or delivered <= 0: + continue + logs = await call( + "analyze_delivery", + { + **window, + "sub_account": group_id, + "include_logs": True, + "page_size": 100, + "max_pages": 3, + }, + ) + records = logs.get("result", logs)["send_logs"]["records"] + detail_payload = await call("get_message_group", {"sub_account": group_id}) + detail = detail_payload.get("result", detail_payload) + if str(detail.get("status")) != "1": + continue + for record in sorted( + records, key=lambda item: item.get("sendTime") or 0, reverse=True + ): + signature, template_id = record.get("signature"), record.get("templateId") + key = (group_id, signature, template_id) + if ( + record.get("receiptState") != "success" + or not signature + or not template_id + or key in seen + or queries + 3 > args.max_queries + ): + continue + seen.add(key) + signatures = rows( + await call( + "list_signatures", + { + "signature": signature, + "sub_account": [group_id], + "page": 1, + "page_size": 100, + }, + ) + ) + if not any( + item.get("Signature") == signature + and str(item.get("Status")) in {"3", "5"} + and item.get("usable") is not False + for item in signatures + ): + continue + templates = rows( + await call( + "list_templates", + { + "template_id": template_id, + "signature": [signature], + "sub_account": [group_id], + "page": 1, + "page_size": 100, + }, + ) + ) + if not any( + str(item.get("TemplateId", item.get("templateId"))) == template_id + and str(item.get("Status", item.get("status"))) in {"3", "5"} + for item in templates + ): + continue + template = await call( + "get_template", + { + "template_id": template_id, + "signature": signature, + "sub_account": group_id, + }, + ) + candidate = { + "groupId": group_id, + "groupName": group.get("SubAccountName"), + "groupCapabilities": detail.get("channelTypeToIndustryConfig"), + "recentDelivered": delivered, + "evidenceMessageId": record["messageId"], + "evidenceReceiptTime": record["receiptTime"], + "template": template, + } + candidates.append(candidate) + print( + json.dumps({"stage": "candidate", **candidate}, ensure_ascii=False), + flush=True, + ) + if len(candidates) >= args.candidates: + break + if len(candidates) >= args.candidates: + break + print( + json.dumps( + { + "stage": "completed", + "groupsScanned": scanned, + "queries": queries, + "candidateCount": len(candidates), + }, + ensure_ascii=False, + ), + flush=True, + ) + + +async def main(args): + sys.path.insert(0, str(Path(args.skill_dir) / "scripts")) + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + skill_api = importlib.import_module("api_client") + credentials = skill_api.SmsApiClient()._credential_resolver.resolve() + uvx = shutil.which("uvx") + if not uvx: + raise RuntimeError("uvx is required") + with tempfile.TemporaryDirectory(prefix="sms-mcp-resource-discovery-") as directory: + parameters = StdioServerParameters( + command=uvx, + args=[ + "--offline", + "--python", + sys.executable, + "--from", + args.wheel, + "mcp-server-sms", + "--transport", + "stdio", + ], + env={ + "UV_CACHE_DIR": args.cache_dir, + "UV_TOOL_DIR": args.tool_dir, + "SMS_MCP_DATABASE_URL": "sqlite:///" + + str(Path(directory) / "state.db"), + "SMS_MCP_ENCRYPTION_KEY": Fernet.generate_key().decode(), + "SMS_MCP_LOCAL_SUBJECT": "read-only-resource-discovery", + "VOLCENGINE_ACCESS_KEY": credentials.access_key, + "VOLCENGINE_SECRET_KEY": credentials.secret_key, + "VOLCENGINE_SESSION_TOKEN": credentials.session_token, + }, + ) + try: + async with ( + stdio_client(parameters) as (read, write), + ClientSession(read, write, read_timeout_seconds=60) as session, + ): + await session.discover() + await discover(session, args) + except Exception as error: # noqa: BLE001 -- redact all transport failures. + + def safe_messages(exception): + if isinstance(exception, BaseExceptionGroup): + return [ + item + for child in exception.exceptions + for item in safe_messages(child) + ] + return [ + skill_api.sanitize_output( + str(exception), + secrets=( + credentials.access_key, + credentials.secret_key, + credentials.session_token, + ), + ) + ] + + print( + json.dumps( + {"stage": "failed", "errors": safe_messages(error)}, + ensure_ascii=False, + ), + flush=True, + ) + return 1 + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--skill-dir", required=True) + parser.add_argument("--wheel", required=True) + parser.add_argument("--cache-dir", required=True) + parser.add_argument("--tool-dir", required=True) + parser.add_argument("--days", type=int, choices=range(1, 91), default=7) + parser.add_argument("--max-queries", type=int, choices=range(15, 121), default=110) + parser.add_argument("--candidates", type=int, choices=range(1, 6), default=3) + parser.add_argument("--exclude-group", action="append", default=[]) + parser.add_argument("--query", choices=sorted(READ_TOOLS)) + parser.add_argument("--arguments", type=json.loads, default={}) + parser.add_argument("--queries", type=json.loads) + try: + raise SystemExit(asyncio.run(main(parser.parse_args()))) + except Exception as error: # noqa: BLE001 -- never expose auth exception details. + print(json.dumps({"stage": "failed", "errorType": type(error).__name__})) + raise SystemExit(1) diff --git a/server/mcp_server_sms/tests/live_readonly.py b/server/mcp_server_sms/tests/live_readonly.py new file mode 100644 index 00000000..bca0efdf --- /dev/null +++ b/server/mcp_server_sms/tests/live_readonly.py @@ -0,0 +1,242 @@ +"""Opt-in acceptance through a standard MCP client and real delegated SMS identity. + +Uses the official Skill's credential resolver in-process after browser login. +Credentials are never printed, written to a file, or passed in argv. This client +only invokes the explicit read-only tools below; it cannot send SMS or apply for +qualifications. The server runs from the exact wheel that will be distributed. +""" + +import argparse +import asyncio +import importlib +import json +import pathlib +import shutil +import sys +import subprocess +import time +import tempfile + +from cryptography.fernet import Fernet +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client, get_default_environment + +READ_TOOLS = ( + ("list_message_groups", {}), + ("list_qualifications", {"page": 1, "page_size": 10}), + ("list_signatures", {"page": 1, "page_size": 10}), + ("list_templates", {"page": 1, "page_size": 10}), +) + + +def tool_payload(result): + if isinstance(result.structured_content, dict): + return result.structured_content + texts = [ + item.text for item in result.content if getattr(item, "type", "") == "text" + ] + try: + value = json.loads("\n".join(texts)) + return value if isinstance(value, dict) else {} + except (ValueError, TypeError): + return {} + + +def summarize(name, result): + payload = tool_payload(result) + if not payload: + return {"tool": name, "status": "failed", "error": "non_structured_tool_error"} + error = payload.get("error") or {} + value = payload.get("result") + count = None + if isinstance(value, list): + count = len(value) + elif isinstance(value, dict): + for key in ("List", "list", "Items", "items"): + if isinstance(value.get(key), list): + count = len(value[key]) + break + return { + "tool": name, + "status": "failed" + if result.is_error or payload.get("success") is False + else "passed", + "requestId": payload.get("request_id"), + "errorCode": error.get("code"), + "returnedCount": count, + } + + +async def run(args): + # Reuse the supported Skill implementation; do not parse its cache ourselves. + sys.path.insert(0, str(pathlib.Path(args.skill_dir).resolve() / "scripts")) + skill_api = importlib.import_module("api_client") + uvx = shutil.which("uvx") + if not uvx: + raise RuntimeError("uvx is required") + command = [ + uvx, + "--python", + sys.executable, + "--from", + str(pathlib.Path(args.wheel).resolve()), + "mcp-server-sms", + ] + installed_at = time.monotonic() + installation = subprocess.run( + command + ["--help"], + env={**get_default_environment(), "UV_CACHE_DIR": args.cache_dir}, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=180, + ) + if installation.returncode: + print( + json.dumps( + { + "stage": "install_failed", + "error": installation.stderr.decode(errors="replace")[:1000], + } + ), + flush=True, + ) + return 1 + print( + json.dumps( + { + "stage": "installed", + "elapsedSeconds": round(time.monotonic() - installed_at, 2), + } + ), + flush=True, + ) + client = skill_api.SmsApiClient() + credentials = client._credential_resolver.resolve() + + with tempfile.TemporaryDirectory(prefix="sms-mcp-live-") as directory: + parameters = StdioServerParameters( + command=uvx, + args=[ + "--python", + sys.executable, + "--from", + str(pathlib.Path(args.wheel).resolve()), + "mcp-server-sms", + "--transport", + "stdio", + ], + env={ + "UV_CACHE_DIR": args.cache_dir, + "SMS_MCP_DATABASE_URL": "sqlite:///" + + str(pathlib.Path(directory) / "state.db"), + "SMS_MCP_ENCRYPTION_KEY": Fernet.generate_key().decode(), + "SMS_MCP_LOCAL_SUBJECT": "live-readonly-acceptance", + "VOLCENGINE_ACCESS_KEY": credentials.access_key, + "VOLCENGINE_SECRET_KEY": credentials.secret_key, + "VOLCENGINE_SESSION_TOKEN": credentials.session_token, + }, + ) + + def safe_errors(error): + if isinstance(error, BaseExceptionGroup): + return [ + item for nested in error.exceptions for item in safe_errors(nested) + ] + return [ + { + "type": type(error).__name__, + "message": skill_api.sanitize_output( + str(error), secrets=tuple(parameters.env.values()) + ), + } + ] + + try: + async with stdio_client(parameters) as (read, write): + async with ClientSession( + read, write, read_timeout_seconds=60 + ) as session: + await session.discover() + tools = await session.list_tools() + print( + json.dumps( + { + "stage": "connected", + "transport": "stdio", + "protocolVersion": session.protocol_version, + "toolCount": len(tools.tools), + }, + ensure_ascii=False, + ), + flush=True, + ) + checks = ( + (("list_message_groups", {"name": args.group_name}),) + if args.group_name is not None + else READ_TOOLS + ) + for name, arguments in checks: + result = await session.call_tool(name, arguments) + summary = summarize(name, result) + if ( + args.group_name is not None + and summary["status"] == "passed" + ): + data = tool_payload(result).get("result", {}) + rows = ( + data + if isinstance(data, list) + else next( + ( + data[key] + for key in ("List", "list", "Items", "items") + if isinstance(data.get(key), list) + ), + [], + ) + ) + summary["firstItemKeys"] = ( + list(rows[0]) + if rows and isinstance(rows[0], dict) + else [] + ) + summary["groups"] = [ + { + "id": row.get("SubAccount"), + "name": row.get("SubAccountName"), + } + for row in rows[:10] + ] + print(json.dumps(summary, ensure_ascii=False), flush=True) + if summary["status"] != "passed": + return 1 + except Exception as error: + print( + json.dumps( + {"stage": "client_failed", "errors": safe_errors(error)}, + ensure_ascii=False, + ), + flush=True, + ) + return 1 + + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--skill-dir", required=True) + parser.add_argument("--wheel", required=True) + parser.add_argument("--cache-dir", required=True) + parser.add_argument("--group-name") + args = parser.parse_args() + try: + return asyncio.run(run(args)) + except Exception as exc: + # Exceptions can contain SDK objects; never dump credentials or tracebacks. + print(json.dumps({"stage": "failed", "errorType": type(exc).__name__})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/mcp_server_sms/tests/test_auth.py b/server/mcp_server_sms/tests/test_auth.py new file mode 100644 index 00000000..6dbd10d6 --- /dev/null +++ b/server/mcp_server_sms/tests/test_auth.py @@ -0,0 +1,74 @@ +import base64 +import json +import time +from types import SimpleNamespace + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from mcp_server_sms.auth import ( + AuthenticationError, + Authenticator, + sms_credentials, + subject_identity, +) + + +def test_jwt_signature_issuer_audience_expiry_and_subject(settings): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + verifier = Authenticator(settings) + verifier._keys = SimpleNamespace( + get_signing_key_from_jwt=lambda token: SimpleNamespace(key=key.public_key()) + ) + claims = { + "iss": settings.oidc_issuer, + "aud": settings.token_audience, + "sub": "one", + "exp": time.time() + 300, + } + for sub in ["one", "two"]: + token = jwt.encode({**claims, "sub": sub}, key, algorithm="RS256") + assert verifier.owner({"authorization": "Bearer " + token}) == subject_identity( + settings.oidc_issuer, sub + ) + for overrides in [ + {"aud": "another-resource"}, + {"iss": "https://foreign.example"}, + {"exp": 1}, + {"sub": ""}, + ]: + token = jwt.encode({**claims, **overrides}, key, algorithm="RS256") + with pytest.raises(AuthenticationError): + verifier.owner({"authorization": "Bearer " + token}) + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with pytest.raises(AuthenticationError): + verifier.owner( + {"authorization": "Bearer " + jwt.encode(claims, other, algorithm="RS256")} + ) + + +def test_http_credentials_never_fall_back_to_process_identity(monkeypatch): + monkeypatch.setenv("VOLCENGINE_ACCESS_KEY", "server-identity") + monkeypatch.setenv("VOLCENGINE_SECRET_KEY", "server-secret") + with pytest.raises(AuthenticationError): + sms_credentials({}) + first = { + "AccessKeyId": "caller-one", + "SecretAccessKey": "secret-one", + "SessionToken": "sts-one", + } + second = {"AccessKeyId": "caller-two", "SecretAccessKey": "secret-two"} + + def load(value): + return sms_credentials( + { + "x-volcengine-credentials": base64.b64encode( + json.dumps(value).encode() + ).decode() + } + ) + + assert load(first).access_key == "caller-one" + assert load(second).access_key == "caller-two" + assert "secret-one" not in repr(load(first)) diff --git a/server/mcp_server_sms/tests/test_client.py b/server/mcp_server_sms/tests/test_client.py new file mode 100644 index 00000000..9a8ddaa6 --- /dev/null +++ b/server/mcp_server_sms/tests/test_client.py @@ -0,0 +1,144 @@ +import datetime +import json + +from mcp_server_sms.core.api_client import SmsApiClient +from mcp_server_sms.core.api_protocol import ( + TransportResponse, + ResponseLostError, + build_signed_request, +) +from mcp_server_sms.core.action_contracts import ACTION_REGISTRY + + +def test_v4_signing_carries_action_version_and_sts(credentials): + request = build_signed_request( + ACTION_REGISTRY["ListSubAccountForAgent"], + {}, + credentials.access_key, + credentials.secret_key, + datetime.datetime(2026, 9, 11, tzinfo=datetime.timezone.utc), + action="ListSubAccountForAgent", + session_token=credentials.session_token, + ) + assert "Action=ListSubAccountForAgent" in request.url + assert "Version=2026-01-01" in request.url + assert request.headers["X-Security-Token"] == credentials.session_token + assert "x-security-token" in request.authorization + assert "cn-north-1/volcSMS/request" in request.authorization + + +def test_mutation_response_lost_and_invalid_response_are_unknown(credentials): + calls = [] + + def lost(request, timeout): + calls.append(1) + raise ResponseLostError("fixture failure") + + client = SmsApiClient(credentials, transport=lost) + assert client.call("SendSmsForAgent", {})["error"]["outcome_unknown"] is True + assert calls == [1] + client = SmsApiClient( + credentials, transport=lambda *args: TransportResponse(200, {}, b"not-json") + ) + assert client.call("SendSmsForAgent", {})["error"]["outcome_unknown"] is True + + +def test_template_response_and_private_qualification_fields(credentials): + result = { + "List": [ + { + "TemplateId": "template-one", + "ShortUrlConfig": {"isEnabled": "0", "unexpected": "private"}, + } + ] + } + client = SmsApiClient( + credentials, + transport=lambda *args: TransportResponse( + 200, {}, json.dumps({"Result": result}).encode() + ), + ) + response = client.call("ListSmsTemplateForAgent", {}) + assert response["success"] + assert response["result"]["List"][0]["ShortUrlConfig"] == {"isEnabled": "0"} + result = {"status": "1", "ticket": "fixture-private-ticket"} + assert ( + client.call("ThreeElementPersonCheckForAgent", {})["result"]["ticket"] + == "fixture-private-ticket" + ) + + +def test_business_error_in_http_200_is_failure(credentials): + client = SmsApiClient( + credentials, + transport=lambda *args: TransportResponse( + 200, + {}, + json.dumps( + { + "ResponseMetadata": { + "RequestId": "fixture", + "Error": {"Code": "RE:0001"}, + } + } + ).encode(), + ), + ) + result = client.call("ListSubAccountForAgent", {}) + assert not result["success"] + assert result["error"]["code"] == "RE:0001" + assert result["request_id"] == "fixture" + + +def test_unstructured_server_error_on_mutation_is_unknown(credentials): + client = SmsApiClient( + credentials, transport=lambda *args: TransportResponse(503, {}, b"{}") + ) + assert client.call("SendSmsForAgent", {})["error"]["outcome_unknown"] + + +def test_identifier_and_hash_values_are_not_phone_masked(credentials): + from mcp_server_sms.core.api_protocol import sanitize_output + + identifier = "aa13812345678bb" + value = sanitize_output( + { + "operationId": identifier, + "digest": identifier, + "SubAccounts": [identifier], + "mobile": "13812345678", + } + ) + assert value["operationId"] == identifier + assert value["digest"] == identifier + assert value["SubAccounts"] == [identifier] + assert value["mobile"] == "138****5678" + result = { + "List": [{"SubAccount": identifier, "SubAccountName": "fixture", "Status": 1}] + } + client = SmsApiClient( + credentials, + transport=lambda *args: TransportResponse( + 200, {}, json.dumps({"Result": result}).encode() + ), + ) + assert ( + client.call("ListSubAccountForAgent", {})["result"]["List"][0]["SubAccount"] + == identifier + ) + + +def test_authorized_upload_url_is_not_rewritten(credentials): + url = "https://upload.example/path?signature=aa13812345678bb" + client = SmsApiClient( + credentials, + transport=lambda *args: TransportResponse( + 200, + {}, + json.dumps({"Result": {"url": url, "file": "fixture-file"}}).encode(), + ), + ) + assert ( + client.call("GetUploadTosURL", {}, preserve_presigned_url=True)["result"]["url"] + == url + ) diff --git a/server/mcp_server_sms/tests/test_inputs.py b/server/mcp_server_sms/tests/test_inputs.py new file mode 100644 index 00000000..4ba3d459 --- /dev/null +++ b/server/mcp_server_sms/tests/test_inputs.py @@ -0,0 +1,113 @@ +import json + +import pytest +from starlette.testclient import TestClient + +from mcp_server_sms.auth import AuthenticationError +from mcp_server_sms.store import StoreError +from mcp_server_sms.server import build_server +from mcp_server_sms.web import create_app + + +def test_local_files_are_confined_and_do_not_overwrite(runtime, owner, tmp_path): + inside = tmp_path / "private.json" + inside.write_text('{"personName":"私密姓名"}') + staged = runtime.inputs.import_local( + "private.json", "qualification_data", "application/json", owner + ) + assert "私密姓名" not in json.dumps(staged, ensure_ascii=False) + with pytest.raises(StoreError): + runtime.inputs.read(staged["fileId"], "someone-else") + with pytest.raises(StoreError): + runtime.inputs.read(staged["fileId"], owner, "batch_csv") + with pytest.raises(StoreError): + runtime.inputs.import_local( + "../outside.json", "qualification_data", "application/json", owner + ) + with pytest.raises(StoreError): + runtime.inputs.import_local( + str(inside), "qualification_data", "application/json", owner + ) + (tmp_path / "link.json").symlink_to(inside) + with pytest.raises(StoreError): + runtime.inputs.import_local( + "link.json", "qualification_data", "application/json", owner + ) + (tmp_path / "linked-dir").symlink_to(tmp_path, target_is_directory=True) + with pytest.raises(StoreError): + runtime.inputs.import_local( + "linked-dir/private.json", "qualification_data", "application/json", owner + ) + with pytest.raises(StoreError): + runtime.inputs.export_local(staged["fileId"], "private.json", owner) + runtime.inputs.export_local(staged["fileId"], "review.json", owner) + assert (tmp_path / "review.json").read_bytes() == inside.read_bytes() + assert (tmp_path / "review.json").stat().st_mode & 0o777 == 0o600 + + +def test_http_file_exchange_requires_same_owner_and_has_no_web_session( + runtime, owner, monkeypatch +): + def authorize(headers): + token = headers.get("authorization") + if token == "Bearer fixture-one": + return owner + if token == "Bearer fixture-two": + return "another-owner" + raise AuthenticationError("missing token") + + monkeypatch.setattr(runtime.auth, "owner", authorize) + with TestClient( + create_app(runtime, build_server(runtime)), base_url="https://testserver" + ) as client: + assert ( + client.post("/files?kind=qualification_data", content=b"{}").status_code + == 401 + ) + response = client.post( + "/files?kind=qualification_data", + content=b'{"personName":"private-name"}', + headers={ + "Authorization": "Bearer fixture-one", + "Content-Type": "application/json", + }, + ) + assert response.status_code == 201 + assert "private-name" not in response.text + file_id = response.json()["fileId"] + assert client.get("/files/" + file_id).status_code == 401 + assert ( + client.get( + "/files/" + file_id, headers={"Authorization": "Bearer fixture-two"} + ).status_code + == 409 + ) + response = client.get( + "/files/" + file_id, headers={"Authorization": "Bearer fixture-one"} + ) + assert response.json() == {"personName": "private-name"} + assert response.headers["cache-control"] == "no-store" + assert "set-cookie" not in response.headers + assert client.get("/auth/login/anything").status_code == 404 + assert client.get("/forms/anything").status_code == 404 + assert ( + client.get("/qualification-static/qualification_wizard.js").status_code + == 404 + ) + too_large = client.post( + "/files?kind=verification_code", + content=b"x" * 1025, + headers={"Authorization": "Bearer fixture-one"}, + ) + assert too_large.status_code == 413 + + +def test_invalid_payloads_never_become_inputs(runtime, owner): + for kind, data in [ + ("verification_code", b'{"code":"12345"}'), + ("verification_code", b'{"code":1234}'), + ("qualification_data", b"[]"), + ("batch_csv", b"\xff"), + ]: + with pytest.raises(StoreError): + runtime.inputs.stage(owner, kind, data, "application/json") diff --git a/server/mcp_server_sms/tests/test_operations.py b/server/mcp_server_sms/tests/test_operations.py new file mode 100644 index 00000000..f2d502bc --- /dev/null +++ b/server/mcp_server_sms/tests/test_operations.py @@ -0,0 +1,335 @@ +import time + +import pytest +from sqlalchemy import update + +from mcp_server_sms.core.api_protocol import ResolvedCredentials +from mcp_server_sms.server import ( + SignatureRequest, + TemplateRequest, + SendRequest, + BatchLaunchRequest, +) +from mcp_server_sms.store import StoreError, records + + +def test_frozen_signature_operation_revalidates_and_deduplicates( + runtime, owner, credentials, sms +): + request = SignatureRequest( + content="测试签名", + purpose=1, + qualification_id=11, + sub_account=["group-one"], + channel_type=["CN_NTC"], + source=1, + ) + result = runtime.prepare("signature", request.model_dump(), owner, credentials) + operation = result["operationId"] + with pytest.raises(StoreError): + runtime.execute(operation, "another-owner", credentials) + with pytest.raises(StoreError): + runtime.execute( + operation, owner, ResolvedCredentials("different-key", "different-secret") + ) + for _ in range(2): + assert runtime.execute(operation, owner, credentials)["success"] is True + assert sum(action == "ApplySmsSignatureV2" for action, _ in sms.calls) == 1 + + +def test_resource_review_changed_since_preview_prevents_write( + runtime, owner, credentials, sms +): + request = SignatureRequest( + content="测试签名", + purpose=1, + qualification_id=11, + sub_account=["group-one"], + channel_type=["CN_NTC"], + source=1, + ) + result = runtime.prepare("signature", request.model_dump(), owner, credentials) + sms.responses["GetSignatureIdentificationList"] = { + "success": True, + "result": {"List": []}, + "error": None, + } + assert ( + runtime.execute(result["operationId"], owner, credentials)["success"] is False + ) + assert not any(action == "ApplySmsSignatureV2" for action, _ in sms.calls) + + +def test_model_options_match_retained_workflow_parser(runtime): + models = [ + ( + "template-preview", + TemplateRequest( + name="test", + content="内容", + channel_type="CN_NTC", + signature=["测试"], + sub_account=["one"], + ), + ), + ( + "send-preview", + SendRequest( + sub_account="one", + signature="测试", + template_id="T1", + mobile=["13800000000"], + template_params={}, + ), + ), + ( + "batch-launch-preview", + BatchLaunchRequest( + sub_account="one", + task_id="task", + file_sha256="a" * 64, + total_count=1, + valid_count=1, + invalid_count=0, + dup_count=0, + ), + ), + ] + for command, model in models: + assert runtime.arguments(command, model.model_dump()).command == command + + +def approved_resources(sms): + template = { + "TemplateId": "T1", + "Status": 3, + "Content": "会议时间为${time}", + "TemplateName": "会议通知", + "ChannelType": "CN_NTC", + "TemplateParams": [{"name": "time"}], + "Signatures": ["测试签名"], + "SubAccounts": ["group-one"], + } + + def ok(result): + return {"success": True, "result": result, "error": None} + + sms.responses.update( + { + "GetSubAccountDetail": ok({"subAccountId": "group-one", "status": 1}), + "ListSignatureForAgent": ok( + { + "List": [ + { + "Signature": "测试签名", + "Status": 3, + "SubAccounts": ["group-one"], + "ChannelTypes": ["CN_NTC"], + } + ] + } + ), + "ListSmsTemplateForAgent": ok({"List": [template]}), + "ListSecondTemplate": ok({"List": [template]}), + "SendSmsForAgent": ok({"MessageId": "fixture-message"}), + "ApplySmsTemplateV2": ok({"templateId": "T1", "status": 1}), + } + ) + return template, ok + + +def test_send_uses_exact_variables_and_masks_preview(runtime, owner, credentials, sms): + import json + from mcp_server_sms.core.workflows import CliError + + approved_resources(sms) + request = SendRequest( + sub_account="group-one", + signature="测试签名", + template_id="T1", + mobile=["13800000000"], + template_params={"time": "明天九点"}, + ) + prepared = runtime.prepare("send", request.model_dump(), owner, credentials) + assert "13800000000" not in json.dumps(prepared) + assert "明天九点" not in json.dumps(prepared, ensure_ascii=False) + assert ( + runtime.operation_result(prepared["operationId"], owner, credentials)["status"] + == "not_executed" + ) + result = runtime.execute(prepared["operationId"], owner, credentials) + assert result["success"] + assert ( + runtime.operation_result(prepared["operationId"], owner, credentials) == result + ) + assert sum(action == "SendSmsForAgent" for action, _ in sms.calls) == 1 + request.template_params = {"wrong": "值"} + with pytest.raises(CliError): + runtime.prepare("send", request.model_dump(), owner, credentials) + + +@pytest.mark.parametrize("outcome_unknown", [False, True]) +def test_execution_result_outlives_preview( + runtime, owner, credentials, sms, outcome_unknown +): + approved_resources(sms) + if outcome_unknown: + sms.responses["SendSmsForAgent"] = { + "success": False, + "error": {"code": "outcome_unknown", "outcome_unknown": True}, + } + request = SendRequest( + sub_account="group-one", + signature="测试签名", + template_id="T1", + mobile=["13800000000"], + template_params={"time": "明天九点"}, + ) + prepared = runtime.prepare("send", request.model_dump(), owner, credentials) + operation_id = prepared["operationId"] + result = runtime.execute(operation_id, owner, credentials) + with runtime.store.engine.begin() as connection: + connection.execute( + update(records) + .where(records.c.id == operation_id) + .values(expires=time.time() - 1) + ) + runtime.store.cleanup() + + assert runtime.operation_result(operation_id, owner, credentials) == result + with pytest.raises(StoreError): + runtime.operation_result(operation_id, "another-owner", credentials) + with pytest.raises(StoreError): + runtime.execute(operation_id, owner, credentials) + assert sum(action == "SendSmsForAgent" for action, _ in sms.calls) == 1 + + +def test_template_and_ordinary_batch_creation_do_not_launch( + runtime, owner, credentials, sms, monkeypatch +): + from mcp_server_sms.core import workflows + from mcp_server_sms.server import BatchRequest, BatchLaunchRequest + + template, ok = approved_resources(sms) + request = TemplateRequest( + name="会议", + content="会议时间为${time}", + channel_type="CN_NTC", + signature=["测试签名"], + sub_account=["group-one"], + template_param=["time"], + ) + prepared = runtime.prepare("template", request.model_dump(), owner, credentials) + assert runtime.execute(prepared["operationId"], owner, credentials)["success"] + uploaded = runtime.inputs.stage( + owner, "batch_csv", b"phone,time\n13800000000,09:00\n", "text/csv" + ) + batch = BatchRequest( + file_id=uploaded["fileId"], + sub_account="group-one", + task_name="fixture-batch", + signature="测试签名", + template_id="T1", + ) + prepared = runtime.prepare_batch(batch.model_dump(), owner, credentials) + sms.responses["GetUploadTosURL"] = ok( + {"url": "https://upload.example/fixture", "file": "fixture-object"} + ) + task = { + "taskId": "task-one", + "subAccount": "group-one", + "status": 2, + "signature": "测试签名", + "templateId": "T1", + "templateName": "会议通知", + "channelType": "CN_NTC", + "fileUrl": "fixture-object", + "scheduled": False, + "totalCount": 1, + "validCount": 1, + "invalidCount": 0, + "dupCount": 0, + } + sms.responses["SetBatchTask"] = ok({"taskId": "task-one", "status": 2}) + sms.responses["GetBatchTaskDetail"] = ok(task) + captured = [] + original = workflows.execute + + def execute(args, client, **kwargs): + return original( + args, client, uploader=lambda url, body: captured.append(body), **kwargs + ) + + monkeypatch.setattr(workflows, "execute", execute) + created = runtime.execute(prepared["operationId"], owner, credentials) + assert created["success"], created + assert captured == [b"phone,time\n13800000000,09:00\n"] + assert not any(action == "ConsentBatchTask" for action, _ in sms.calls) + metadata = created["result"] + launch = BatchLaunchRequest( + sub_account="group-one", + task_id="task-one", + file_sha256=metadata["fileSha256"], + total_count=1, + valid_count=1, + invalid_count=0, + dup_count=0, + ) + planned = runtime.prepare("batch_launch", launch.model_dump(), owner, credentials) + sms.responses["ConsentBatchTask"] = ok({"taskId": "task-one"}) + assert runtime.execute(planned["operationId"], owner, credentials)["success"] + assert sum(action == "ConsentBatchTask" for action, _ in sms.calls) == 1 + + +def test_batch_rejects_otp_and_foreign_files(runtime, owner, credentials, sms): + from mcp_server_sms.core.workflows import CliError + from mcp_server_sms.server import BatchRequest + + template, ok = approved_resources(sms) + uploaded = runtime.inputs.stage( + owner, "batch_csv", b"phone,time\n13800000000,09:00\n", "text/csv" + ) + request = BatchRequest( + file_id=uploaded["fileId"], + sub_account="group-one", + task_name="batch", + signature="测试签名", + template_id="T1", + ) + with pytest.raises(StoreError): + runtime.prepare_batch(request.model_dump(), "other-owner", credentials) + template["ChannelType"] = "CN_OTP" + with pytest.raises(CliError): + runtime.prepare_batch(request.model_dump(), owner, credentials) + + +async def test_template_detail_exposes_reviewable_content_and_checks_binding( + runtime, owner, credentials, sms, monkeypatch +): + from mcp_server_sms.server import build_server + from mcp.server.mcpserver.exceptions import ToolError + + template, _ = approved_resources(sms) + monkeypatch.setattr(runtime, "caller", lambda ctx: (owner, credentials)) + server = build_server(runtime) + result = await server.call_tool( + "get_template", + {"template_id": "T1", "signature": "测试签名", "sub_account": "group-one"}, + ) + detail = result.structured_content + assert detail["content"] == "会议时间为${time}" + assert detail["variables"] == ["time"] + assert detail["contentRedacted"] is False + with pytest.raises(ToolError): + await server.call_tool( + "get_template", + {"template_id": "T1", "signature": "其他签名", "sub_account": "group-one"}, + ) + template["Content"] = "联系13800000000,会议时间为${time}" + result = await server.call_tool( + "get_template", + {"template_id": "T1", "signature": "测试签名", "sub_account": "group-one"}, + ) + assert result.structured_content["contentRedacted"] is True + assert "13800000000" not in result.structured_content["content"] + assert all(action == "ListSecondTemplate" for action, _ in sms.calls) diff --git a/server/mcp_server_sms/tests/test_postgresql.py b/server/mcp_server_sms/tests/test_postgresql.py new file mode 100644 index 00000000..ba7f5dad --- /dev/null +++ b/server/mcp_server_sms/tests/test_postgresql.py @@ -0,0 +1,51 @@ +"""Run against a disposable CI database, in an isolated schema per test.""" + +import os +import uuid + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.engine import make_url +from sqlalchemy.schema import CreateSchema, DropSchema + +from mcp_server_sms.store import Store + + +@pytest.fixture +def postgres_url(): + value = os.environ.get("SMS_MCP_TEST_DATABASE_URL") + if not value: + pytest.skip("No dedicated PostgreSQL test database configured") + engine = create_engine(value) + schema = "sms_mcp_test_" + uuid.uuid4().hex + with engine.begin() as connection: + connection.execute(CreateSchema(schema)) + url = make_url(value).update_query_dict({"options": "-csearch_path=" + schema}) + try: + yield url + finally: + with engine.begin() as connection: + connection.execute(DropSchema(schema, cascade=True)) + engine.dispose() + + +def test_postgresql_shared_draft_and_mutation_guard(postgres_url, settings, owner): + first = Store(postgres_url, settings.encryption_key) + second = Store(postgres_url, settings.encryption_key) + try: + flow = first.create(owner, "form", {"revision": 1}, 300) + with second.locked(flow, owner, "form") as (record, lease): + first.save(flow, owner, {"revision": 2}, lease=lease) + assert second.get(flow, owner, "form")["data"]["revision"] == 2 + calls = [] + + def submit(): + calls.append(1) + return {"success": True} + + first.once(owner, "pg-submit", {}, 300, submit) + assert second.once(owner, "pg-submit", {}, 300, submit)["success"] + assert calls == [1] + finally: + first.engine.dispose() + second.engine.dispose() diff --git a/server/mcp_server_sms/tests/test_qualification.py b/server/mcp_server_sms/tests/test_qualification.py new file mode 100644 index 00000000..982ba9f5 --- /dev/null +++ b/server/mcp_server_sms/tests/test_qualification.py @@ -0,0 +1,302 @@ +import json +from dataclasses import replace +from types import SimpleNamespace +from mcp.server.mcpserver.context import Context + +import pytest + +from mcp_server_sms.core import qualification +from mcp_server_sms.store import StoreError +from mcp_server_sms.runtime import Runtime, GuardedClient +from mcp_server_sms.server import build_server + + +def test_submit_and_resume_on_another_instance( + runtime, settings, owner, credentials, ready_flow, sms +): + prepared = runtime.prepare_qualification(ready_flow, None, owner, credentials) + another = runtime.prepare_qualification(ready_flow, None, owner, credentials) + second = Runtime(settings) + result = second.execute(prepared["operationId"], owner, credentials) + assert result["success"] and result["result"]["qualificationId"] == 123 + assert runtime.execute(prepared["operationId"], owner, credentials) == result + assert ( + runtime.execute(another["operationId"], owner, credentials)["result"][ + "qualificationId" + ] + == 123 + ) + status = runtime.qualification_status(ready_flow, owner, credentials) + assert status["result"]["status"] == "submitted_for_review" + assert ( + "sections" + not in runtime.store.get(ready_flow, owner, "qualification")["data"]["state"] + ) + assert ( + sum(action == "ApplySignatureIdentificationForAgent" for action, _ in sms.calls) + == 1 + ) + assert "测试经办人" not in json.dumps(prepared, ensure_ascii=False) + assert "fixture-ticket" not in json.dumps(prepared) + + +def test_changed_preview_does_not_submit(runtime, owner, credentials, ready_flow, sms): + prepared = runtime.prepare_qualification(ready_flow, None, owner, credentials) + runtime.change_qualification( + ready_flow, lambda draft: draft.set_base("变更后的资质", 1), owner, credentials + ) + result = runtime.execute(prepared["operationId"], owner, credentials) + assert result["success"] is False + assert result["error"]["code"] == "stale_qualification_preview" + assert not any( + action == "ApplySignatureIdentificationForAgent" for action, _ in sms.calls + ) + + +def test_unknown_submission_is_not_retried_or_cancelled( + runtime, owner, credentials, ready_flow, sms +): + sms.responses["ApplySignatureIdentificationForAgent"] = { + "success": False, + "request_id": "unknown-fixture", + "error": {"code": "outcome_unknown", "outcome_unknown": True}, + } + prepared = runtime.prepare_qualification(ready_flow, None, owner, credentials) + assert ( + runtime.execute(prepared["operationId"], owner, credentials)["success"] is False + ) + result = runtime.cancel_qualification(ready_flow, owner, credentials) + assert result["result"]["status"] == "qualification_submission_outcome_unknown" + assert result["result"]["outcomeUnknown"] is True + assert ( + sum(action == "ApplySignatureIdentificationForAgent" for action, _ in sms.calls) + == 1 + ) + + +def test_foreign_and_cancelled_drafts_never_return_material( + runtime, owner, credentials, ready_flow +): + with pytest.raises(StoreError): + runtime.qualification_status(ready_flow, "foreign-owner", credentials) + runtime.cancel_qualification(ready_flow, owner, credentials) + result = runtime.qualification_status(ready_flow, owner, credentials) + assert result["status"] == "finished" + assert "sections" not in json.dumps(result) + with pytest.raises(StoreError): + runtime.prepare_qualification(ready_flow, None, owner, credentials) + + +def test_business_change_preserves_name_and_invalidates_checks( + runtime, owner, credentials, ready_flow +): + original = runtime.store.get(ready_flow, owner, "qualification")["data"]["state"] + document = dict(original["sections"]["business"]) + document["businessCertificateName"] = "变更后的测试企业名称与资质名称不同" + file_id = runtime.inputs.stage( + owner, "qualification_data", json.dumps(document).encode(), "application/json" + )["fileId"] + result = runtime.update_qualification_information( + ready_flow, "business", file_id, owner, credentials + ) + assert result["revision"] > 7 + assert result["materialName"] == "测试资质" + assert result["readyForPreview"] is False + assert "business_check" in result["missing"] + + +def test_sms_result_recovers_after_process_loss( + runtime, owner, credentials, ready_flow, sms +): + guard = GuardedClient( + sms, + runtime.store, + owner, + f"qualification:{ready_flow}:submit:7", + runtime.settings.operation_ttl, + ) + guard.call("ApplySignatureIdentificationForAgent", {"fixture": "submitted"}) + result = runtime.qualification_status(ready_flow, owner, credentials) + assert result["result"]["qualificationId"] == 123 + assert ( + runtime.cancel_qualification(ready_flow, owner, credentials)["result"]["status"] + == "submitted_for_review" + ) + assert ( + "sections" + not in runtime.store.get(ready_flow, owner, "qualification")["data"]["state"] + ) + + +async def test_complete_application_uses_public_tools_without_web( + runtime, owner, credentials, sms, monkeypatch, tmp_path +): + runtime = Runtime( + replace(runtime.settings, public_url="", oidc_issuer="", token_audience="") + ) + monkeypatch.setattr(runtime, "caller", lambda ctx: (owner, credentials)) + server = build_server(runtime) + context = Context(request_context=SimpleNamespace(request=None), mcp_server=server) + + async def call(name, args): + response = await server.call_tool(name, args, context) + assert not response.is_error, response.content + return response.structured_content + + requirements = await call("get_qualification_requirements", {}) + assert "personIDCard" in requirements["privateInputSchemas"]["person"]["properties"] + draft = await call("create_qualification_draft", {"name": "接口验收", "purpose": 1}) + draft_id = draft["draftId"] + assert "url" not in draft + business = { + "businessCertificateType": 1, + "businessCertificateName": "测试企业", + "unifiedSocialCreditIdentifier": "911101080000000000", + "legalPersonName": "测试法人", + "businessCertificateValidityPeriodStart": "2020-01-01", + "businessCertificateValidityPeriodEnd": "2099-12-31", + } + person = { + "certificateType": 0, + "personName": "测试经办人", + "personIDCard": "110101199001010000", + "personMobile": "", + } + for target, data in [("business", business), ("operator", person)]: + name = target + ".json" + (tmp_path / name).write_text(json.dumps(data)) + uploaded = await call( + "import_input_file", + { + "relative_path": name, + "kind": "qualification_data", + "content_type": "application/json", + }, + ) + saved = await call( + "update_qualification_information", + {"draft_id": draft_id, "target": target, "file_id": uploaded["fileId"]}, + ) + assert data.get("personName", "测试法人") not in json.dumps( + saved, ensure_ascii=False + ) + action = ( + "ThreeElementEnterpriseCheckForAgent" + if target == "business" + else "ThreeElementPersonCheckForAgent" + ) + sms.responses[action] = { + "success": True, + "result": {"status": "0", "ticket": "private-ticket-" + target}, + } + await call( + "check_qualification_information", {"draft_id": draft_id, "target": target} + ) + await call( + "set_qualification_responsible", {"draft_id": draft_id, "same_operator": True} + ) + ready = await call("get_qualification_draft", {"draft_id": draft_id}) + assert ready["readyForPreview"] + prepared = await call("prepare_qualification_application", {"draft_id": draft_id}) + assert "private-ticket" not in json.dumps(prepared) + submitted = await call( + "execute_sms_operation", {"operation_id": prepared["operationId"]} + ) + assert submitted["result"]["qualificationId"] == 123 + body = next( + params + for action, params in sms.calls + if action == "ApplySignatureIdentificationForAgent" + ) + assert body["materialName"] == "接口验收" + assert "legalPerson" not in body + assert body["responsibleCheckTicket"] == body["operatorCheckTicket"] + + +def test_ocr_result_is_private_and_uploaded_material_invalidates_preview( + runtime, owner, credentials, ready_flow, monkeypatch +): + monkeypatch.setattr( + qualification, + "upload_qualification_file_bytes", + lambda *args: {"imageUri": "private-image-uri", "imageSuffix": "png"}, + ) + monkeypatch.setattr( + qualification, + "ocr_business_certificate_image", + lambda *args: { + "legalPersonName": "私密法人姓名", + "businessCertificateName": "测试企业", + }, + ) + content = b"\x89PNG\r\n\x1a\n" + b"fixture" + uploaded = runtime.inputs.stage(owner, "qualification_image", content, "image/png") + result = runtime.upload_qualification_document( + ready_flow, + uploaded["fileId"], + {"target": "business", "certificate_type": 1}, + owner, + credentials, + ) + assert not result["readyForPreview"] + assert "private-image-uri" not in json.dumps(result) + recognized = runtime.recognize_qualification_document( + ready_flow, "business", owner, credentials + ) + assert "私密法人姓名" not in json.dumps(recognized, ensure_ascii=False) + assert ( + runtime.inputs.document(recognized["reviewFileId"], owner)["legalPersonName"] + == "私密法人姓名" + ) + + +def test_code_preview_verification_and_removal( + runtime, owner, credentials, ready_flow, sms +): + with runtime.store.locked(ready_flow, owner, "qualification") as (record, lease): + state = record["data"]["state"] + state["mobileVerificationRequired"] = True + state["sections"]["operator"]["personMobile"] = "13800000000" + runtime.store.save(ready_flow, owner, record["data"], lease=lease) + sms.responses["SendSmsVerifyCodeByMobile"] = { + "success": True, + "result": {"messageId": "verification-fixture"}, + } + sms.responses["CheckSmsVerifyCodeByMobile"] = { + "success": True, + "result": {"status": 0}, + } + prepared = runtime.prepare_qualification(ready_flow, "operator", owner, credentials) + assert "13800000000" not in json.dumps(prepared) + assert runtime.execute(prepared["operationId"], owner, credentials)["success"] + repeated = runtime.prepare_qualification(ready_flow, "operator", owner, credentials) + assert ( + runtime.execute(repeated["operationId"], owner, credentials)["success"] is False + ) + uploaded = runtime.inputs.stage( + owner, "verification_code", b'{"code":"1234"}', "application/json" + ) + result = runtime.verify_qualification_code( + ready_flow, + "operator", + uploaded["fileId"], + "11111111-1111-4111-8111-111111111111", + owner, + credentials, + ) + assert result["mobileVerifications"]["operator"] is True + assert "1234" not in json.dumps(result) + with pytest.raises(StoreError): + runtime.inputs.read(uploaded["fileId"], owner) + assert sum(action == "SendSmsVerifyCodeByMobile" for action, _ in sms.calls) == 1 + + +def test_cancelled_preview_cannot_submit(runtime, owner, credentials, ready_flow, sms): + prepared = runtime.prepare_qualification(ready_flow, None, owner, credentials) + runtime.cancel_qualification(ready_flow, owner, credentials) + result = runtime.execute(prepared["operationId"], owner, credentials) + assert result["success"] is False + assert result["error"]["code"] == "qualification_draft_cancelled" + assert not any( + action == "ApplySignatureIdentificationForAgent" for action, _ in sms.calls + ) diff --git a/server/mcp_server_sms/tests/test_store.py b/server/mcp_server_sms/tests/test_store.py new file mode 100644 index 00000000..c94d6df1 --- /dev/null +++ b/server/mcp_server_sms/tests/test_store.py @@ -0,0 +1,83 @@ +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest +from sqlalchemy import select, update + +from mcp_server_sms.store import Store, StoreError, records + + +def test_shared_encrypted_draft_survives_new_runtime(settings, owner): + first = Store(settings.database_url, settings.encryption_key) + draft = first.create(owner, "form", {"secret": "fixture-private-data"}, 300) + second = Store(settings.database_url, settings.encryption_key) + assert second.get(draft, owner, "form")["data"]["secret"] == "fixture-private-data" + with first.engine.connect() as connection: + serialized = str(connection.execute(select(records)).all()) + assert "fixture-private-data" not in serialized + assert owner not in serialized + with pytest.raises(StoreError): + second.get(draft, "another-owner", "form") + with pytest.raises(StoreError): + second.get(draft, owner, "file") + + +def test_mutation_deduplicates_across_instances(settings, owner): + a = Store(settings.database_url, settings.encryption_key) + b = Store(settings.database_url, settings.encryption_key) + started, finish = threading.Event(), threading.Event() + calls = [] + + def send(): + calls.append(1) + started.set() + assert finish.wait(5) + return {"success": True, "result": {"messageId": "one"}} + + with ThreadPoolExecutor(2) as pool: + pending = pool.submit( + a.once, owner, "send-one", {"message": "fixture"}, 300, send + ) + assert started.wait(5) + result = b.once(owner, "send-one", {"message": "fixture"}, 300, send) + assert result["error"]["outcome_unknown"] is True + finish.set() + assert pending.result()["success"] is True + assert ( + b.once(owner, "send-one", {"message": "fixture"}, 300, send)["success"] is True + ) + assert calls == [1] + with pytest.raises(StoreError): + b.once(owner, "send-one", {"message": "changed"}, 300, send) + + +def test_unknown_mutation_is_never_automatically_replayed(runtime, owner): + calls = [] + + def send(): + calls.append(1) + raise TimeoutError("may have reached SMS") + + for _ in range(2): + assert runtime.store.once(owner, "unknown", {}, 300, send)["error"][ + "outcome_unknown" + ] + assert calls == [1] + + +def test_expiration_and_lost_lease_fail_closed(runtime, owner): + draft = runtime.store.create(owner, "form", {"value": 1}, 300) + with runtime.store.locked(draft, owner, "form") as (record, lease): + with pytest.raises(StoreError): + runtime.store.save(draft, owner, {"value": 2}, lease="wrong-lease") + runtime.store.save(draft, owner, {"value": 3}, lease=lease) + with runtime.store.engine.begin() as connection: + connection.execute( + update(records).where(records.c.id == draft).values(expires=time.time() - 1) + ) + with pytest.raises(StoreError): + runtime.store.get(draft, owner, "form") + runtime.store.cleanup() + with runtime.store.engine.connect() as connection: + assert connection.execute(select(records)).all() == [] diff --git a/server/mcp_server_sms/tests/test_transport.py b/server/mcp_server_sms/tests/test_transport.py new file mode 100644 index 00000000..8022db3e --- /dev/null +++ b/server/mcp_server_sms/tests/test_transport.py @@ -0,0 +1,106 @@ +from starlette.testclient import TestClient +from mcp_server_sms.server import build_server +from mcp_server_sms.web import create_app + + +def rpc(client, method, arguments=None): + params = { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "sms-tests", + "version": "1.0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + } + } + if arguments: + params.update(arguments) + return client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + headers={ + "Authorization": "Bearer fixture-token", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": "2026-07-28", + "MCP-Method": method, + **( + {"Mcp-Name": arguments["name"]} + if arguments and "name" in arguments + else {} + ), + }, + ) + + +def test_0728_discovery_and_tools_without_initialize_or_session( + runtime, owner, monkeypatch +): + monkeypatch.setattr(runtime.auth, "owner", lambda headers: owner) + app = create_app(runtime, build_server(runtime)) + with TestClient(app, base_url="https://testserver") as client: + for method in ["server/discover", "tools/list"]: + response = rpc(client, method) + assert response.status_code == 200, response.text + assert "error" not in response.json(), response.text + assert "mcp-session-id" not in response.headers + tools = response.json()["result"]["tools"] + names = {tool["name"] for tool in tools} + assert "create_qualification_draft" in names + assert "prepare_qualification_application" in names + assert "open_qualification_application" not in names + assert "open_batch_file_upload" not in names + assert "prepare_batch_task" in names + assert not any( + "public_resource" in name or "notification" in name for name in names + ) + for tool in tools: + assert "ctx" not in tool["inputSchema"].get("properties", {}) + + +def test_mcp_requires_verified_token(runtime): + with TestClient( + create_app(runtime, build_server(runtime)), base_url="https://testserver" + ) as client: + response = client.post("/mcp", json={}) + assert response.status_code == 401 + assert "resource_metadata" in response.headers["www-authenticate"] + assert ( + client.get("/.well-known/oauth-protected-resource/mcp").json()["resource"] + == "https://testserver/mcp" + ) + + +async def test_tool_results_have_usable_structured_content( + runtime, owner, credentials, sms, monkeypatch +): + monkeypatch.setattr(runtime, "caller", lambda ctx: (owner, credentials)) + server = build_server(runtime) + result = await server.call_tool("list_message_groups", {}) + assert result.is_error is False + assert result.structured_content["success"] is True + assert result.structured_content["result"]["List"][0]["SubAccount"] == "group-one" + assert all(tool.output_schema is not None for tool in await server.list_tools()) + + +def test_http_tools_cannot_import_local_files(runtime, owner, monkeypatch, tmp_path): + (tmp_path / "private.json").write_text('{"private":"customer-data"}') + monkeypatch.setattr(runtime.auth, "owner", lambda headers: owner) + with TestClient( + create_app(runtime, build_server(runtime)), base_url="https://testserver" + ) as client: + response = rpc( + client, + "tools/call", + { + "name": "import_input_file", + "arguments": { + "relative_path": "private.json", + "kind": "qualification_data", + "content_type": "application/json", + }, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["result"]["isError"] is True + assert "customer-data" not in response.text diff --git a/server/mcp_server_sms/uv.lock b/server/mcp_server_sms/uv.lock new file mode 100644 index 00000000..59d74bcb --- /dev/null +++ b/server/mcp_server_sms/uv.lock @@ -0,0 +1,1586 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "build" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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 = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, +] + +[[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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mcp-server-sms" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "sqlalchemy" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +test = [ + { name = "build" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "twine" }, +] + +[package.metadata] +requires-dist = [ + { name = "build", marker = "extra == 'test'", specifier = ">=1" }, + { name = "cryptography", specifier = ">=45" }, + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "mcp", extras = ["cli"], specifier = ">=2.2,<3" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pydantic", specifier = ">=2.11,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1" }, + { name = "ruff", marker = "extra == 'test'", specifier = ">=0.16,<1" }, + { name = "sqlalchemy", specifier = ">=2.0,<3" }, + { name = "starlette", specifier = ">=0.47,<1" }, + { name = "twine", marker = "extra == 'test'", specifier = ">=6" }, + { name = "uvicorn", specifier = ">=0.35,<1" }, +] +provides-extras = ["test"] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" }, + { url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" }, + { url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "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 = "psycopg" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/8fb739d0f6bba247b9b93c9840c402a4f88545be5f1d4b02b23366371c00/psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4", size = 166508, upload-time = "2026-08-31T22:45:43.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/2e/d0a645bcaadde68bd6d93c43f02f14b0191bdda367ce3f7722abe3da744a/psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e", size = 213598, upload-time = "2026-08-31T22:39:02.184Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/67/ac6b3dfd2d495f8599f7179f8082af4b3be72caa44942ddb45e9613338eb/psycopg_binary-3.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6bd84e4cf67930f26f015dec33f615472b9c5871d46408efe112dbc1bc021de", size = 4720383, upload-time = "2026-08-31T22:40:22.262Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7f/c3870c7e5ba6b4444e3e5401c79d5e83afe2c7045844eabb25d5b39adecb/psycopg_binary-3.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fdbeb38c9b7ca8fa57a7bda3802bedb62f4494ad3dd46c7dd36dc3f77fd5093f", size = 4768854, upload-time = "2026-08-31T22:40:26.639Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a2/04834fb8a2af14d42e48abeb61ace0a4fee2d6f516723d897cff16fce677/psycopg_binary-3.3.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:06de14ac978a2d53e864069fb5487075c6e3cfb0740f1bfd7017bc8b9942067f", size = 5588618, upload-time = "2026-08-31T22:40:36.524Z" }, + { url = "https://files.pythonhosted.org/packages/de/0d/2eed6e7f8c5ada9e08cbdca4b78083df12f5c2a8d13fa0c62cd6b2b1d762/psycopg_binary-3.3.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8b66353b20e79bf7ac0a80f03ae97f522ccbbf909d687eec62f112e56c0276d", size = 5259471, upload-time = "2026-08-31T22:40:46.43Z" }, + { url = "https://files.pythonhosted.org/packages/96/66/2347889e693502e8473a60b3defac0300829007275a21980a2197c200418/psycopg_binary-3.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af5084124fb2fd16557073822519dfe8c389636a16adef661a4c0c3918733171", size = 6851181, upload-time = "2026-08-31T22:40:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/11/039d7bbdc8ac28292a2fb9f54a7385116c7d31a3bf9c87e71eda154b32b9/psycopg_binary-3.3.5-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1344fd57a19737554670e67aecabd4fb37cd7937f2925840009645d641117e5a", size = 5098916, upload-time = "2026-08-31T22:40:57.505Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/5f77284d0c682106279da7d9e26527ae99f3241a91f0549b033c84441404/psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2719fe19a4da752c4110cc767716d0a5bdb760d1153d89018bb7c9c61717bde5", size = 4617043, upload-time = "2026-08-31T22:41:04.555Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4a/c07000298ffa3eaf25a720a5fd4bc9326676e99712722ca1accb40071e66/psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:893ce86a4b997f6ca1261a7826db2506727332a6ff66646fa7f024b39b5e630e", size = 4310458, upload-time = "2026-08-31T22:41:11.72Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0a/cc7da5bb2bed198354f8dcc7c5dab3f1473edf2b6a177c1e8ec3fa9841ea/psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a5e45e4bb68656253ce5c7a344c0a425c293581eba954d7fd7c2e4b2dc9f3038", size = 4039320, upload-time = "2026-08-31T22:41:20.783Z" }, + { url = "https://files.pythonhosted.org/packages/17/60/f65755f1ab74223b437f1d366f63915b6f5e1292234de47643f898c29faf/psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca8af7c0454cdce235d4aedcb5528857468f1490202d25e24fc7af40e176d563", size = 4342427, upload-time = "2026-08-31T22:41:27.068Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3c/6b1ea5b5544fa51ee6da8e99e96ca6a639ed1e0da2e335467893f14d3182/psycopg_binary-3.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:7b443f943abfe35aa5a776630cea27c9348aa66659286cee0b99084332252080", size = 3665235, upload-time = "2026-08-31T22:41:30.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/83/ba396428a4fb6b70f0dd41315ad86a2c14441b7214afd47b2d49cb450b78/psycopg_binary-3.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25105f9b46bdf2a30fcb67f56976ed66f6855941ae16bc024192609b917d493c", size = 4700169, upload-time = "2026-08-31T22:41:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/4f/56/b23c5978e55cf4effdc5a3e13a17d69a580a487993e01b7c3768dd24281c/psycopg_binary-3.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0249c3e960cdee686000eb77169fb6590105c05bacc37e057ccdffdcd8e6ebde", size = 4763037, upload-time = "2026-08-31T22:41:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d8/b41108bfe194b4098076c8b873b05ec2ca9582445eccfd9075970d7b948e/psycopg_binary-3.3.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5698ab5941a4d138c30fef858588e651fe7d583280cd6e41832825ad9e747750", size = 5546232, upload-time = "2026-08-31T22:41:55.817Z" }, + { url = "https://files.pythonhosted.org/packages/21/d1/0f244dfef389e52e9dc3056f2a9033d1f6901e97d24d9a9c8b836e32ab6b/psycopg_binary-3.3.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672", size = 5227752, upload-time = "2026-08-31T22:42:03.776Z" }, + { url = "https://files.pythonhosted.org/packages/31/88/a4781365f09807fb91435e2d00096f3f6ae5d06bce15ef3c3c385dc22772/psycopg_binary-3.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2a61e8147902771df7efe14062a3c8736347850d0d8befcf048235752504f2e", size = 6824658, upload-time = "2026-08-31T22:42:13.263Z" }, + { url = "https://files.pythonhosted.org/packages/65/f1/072c4a46287644694731b3e40fab120ebacf6d153cce7ebf7a5b208f5561/psycopg_binary-3.3.5-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f7e1e45aad410e20de45df2b159df68ff6c8dbf47a3501f806c4489b27f4ad2b", size = 5061439, upload-time = "2026-08-31T22:42:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/1776a95c16941b8bbce89407cc7cf9ea3fd557efb503a40873c9e2b6394f/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c09775c549b40b274206e1b043c5e5b5af39666e85c98382a30bd05d23ab677b", size = 4588586, upload-time = "2026-08-31T22:42:25.23Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/44ec87b9a74faebe966856307b65c0d35ee6321ce509cdd0e8d6a3f5338b/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cf0e5e63ee86098299c673992053d556c489ba9ae6aca6cb6e24d16a8e0b09e6", size = 4265161, upload-time = "2026-08-31T22:42:31.864Z" }, + { url = "https://files.pythonhosted.org/packages/24/88/cf181df5651395a80afc520f5fefac72beb035c0c9d58ab7fcc880c9b221/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c065531e8c1815276f50dbfa283e3a7f022671414cdda6fa9a16794dd53b28f9", size = 3998727, upload-time = "2026-08-31T22:42:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/c72a1107647db4bb3f534c7fe1b57b1957d9c099e795f5aa81f4a2e0c312/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:df9853b832b7b916e02ef68e0d5403a7dab2d5c1ddfe94f22b1b155eb862622f", size = 4310119, upload-time = "2026-08-31T22:42:46.403Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/452620608cafff164737e20b42ebffee8151b865ee171ba0d6692a560a44/psycopg_binary-3.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:35885e333020fc152d27bea1a494bef13b2e68f6fd92b6229015e93539152008", size = 3648197, upload-time = "2026-08-31T22:42:51.575Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1c/e718752cc63cf4e99e4a10fd36e3a3364dabdd0819484a24c0d79fbb9685/psycopg_binary-3.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4", size = 4704421, upload-time = "2026-08-31T22:42:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/af/cf/a0e748e27c09b92738e4460582d121ba1908be3e36791e150f435e54b332/psycopg_binary-3.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f", size = 4765054, upload-time = "2026-08-31T22:43:05.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/62/0cbac0266d56c94dd1f702d9af2b5d54bf80b6e658048f4f2c5bd63dd7e3/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7", size = 5547137, upload-time = "2026-08-31T22:43:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3a/73c6f8871f38fc07a9c0b4cbc9467beb116a2783cecf87dfa53900a396dc/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0", size = 5227577, upload-time = "2026-08-31T22:43:20.767Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/9f17b9f4d297b774dc574199c4dcd02dadc32a00c4056265918de5c70635/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552", size = 6824606, upload-time = "2026-08-31T22:43:33.698Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/d84dadd94a004dbbb43ce0579f1f766fdc6b8cbef745090e24bea77b5283/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586", size = 5060854, upload-time = "2026-08-31T22:43:42.258Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/e5078be2c7d2490c0d6cd4cb7b3601aec44be2fa8b3fe927e9419b06004f/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e", size = 4589511, upload-time = "2026-08-31T22:43:50.012Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/08dfb5b18fa482e025864fd91a022310d0782c42e4cf5dda5d0e010b6790/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311", size = 4268144, upload-time = "2026-08-31T22:43:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/88/b9/cb01dc1d63f3241b49b2ca7fb9f98d0f5c76127f0dbb9440635a6ad0233e/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab", size = 4001036, upload-time = "2026-08-31T22:44:04.429Z" }, + { url = "https://files.pythonhosted.org/packages/95/49/7c17dd832c05b380562ff2ff5f6ab2bcaeca8b7fff2c2b355854368a5bdb/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16", size = 4313112, upload-time = "2026-08-31T22:44:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/b0b2f887185d6a2ec0b3bef948cc07656d2d1a5d96fa7f2bb03f6ef06ca4/psycopg_binary-3.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b", size = 3647313, upload-time = "2026-08-31T22:44:19.139Z" }, + { url = "https://files.pythonhosted.org/packages/46/7f/4e2395da194558533bd9c31f35e4dc58ecbbae6a7176b0d2f72d629e8a51/psycopg_binary-3.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3", size = 4712612, upload-time = "2026-08-31T22:44:25.723Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/fdb2093c8ccd7048449156db526ad746740cf34fe9c01e5dc1b7a7a8b257/psycopg_binary-3.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9", size = 4775139, upload-time = "2026-08-31T22:44:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/31/52/5195e87960715f7be2005761b72d56fce4e7757d5333fd40384f071c2de1/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e", size = 5556807, upload-time = "2026-08-31T22:44:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/f7/42/2d616210a91e1327516ed5ae71961aaa31bb740e4a61d7190f2221685a40/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750", size = 5236206, upload-time = "2026-08-31T22:44:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/08/2e/e54b0d4cc263b3526e3728bb50a69660d5e79e52255ccd2a1a71e40e6f9a/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594", size = 6838066, upload-time = "2026-08-31T22:44:55.701Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/ec22a110f81a44c411965982097efeee86006bdd5a1f51f628318106c84c/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5", size = 5072036, upload-time = "2026-08-31T22:45:02.838Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/7bc3c3ac769ab4fe0c619f2179b4aab3ae043f4d478283dd8279d24c0be4/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75", size = 4604058, upload-time = "2026-08-31T22:45:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/8e7414f7dc10b2959e664330cdcf393e412f67355975dafa876cef265264/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60", size = 4284766, upload-time = "2026-08-31T22:45:17.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/33f293c1d3ee9114f47e9ef4880f31c9de864e84a9b09454c26e106c25ed/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43", size = 4011958, upload-time = "2026-08-31T22:45:24.792Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/8e38840e5a7d006987bbc9acb29951912253fa50549a4db92b3aa535f089/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04", size = 4323273, upload-time = "2026-08-31T22:45:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" }, +] + +[[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 = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "readme-renderer" +version = "46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz", hash = "sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90", size = 38382, upload-time = "2026-08-28T15:18:32.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl", hash = "sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560", size = 14239, upload-time = "2026-08-28T15:18:31.132Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "twine" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, +] + +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]