From 9d171d9361a45c548f14ab3cd5dee37402f1ba37 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:04:49 +0000 Subject: [PATCH 1/2] SDK regeneration --- .fern/metadata.json | 8 +- .github/workflows/manual-publish.yml | 46 -- AGENTS.md | 90 ---- README.md | 36 +- poetry.lock | 494 ++++++++--------- pyproject.toml | 6 +- reference.md | 390 ++++++++++++-- src/speechify/__init__.py | 32 +- src/speechify/audio/__init__.py | 11 +- src/speechify/audio/client.py | 248 ++++++++- src/speechify/audio/raw_client.py | 507 +++++++++++++++++- src/speechify/audio/types/__init__.py | 11 +- ...am_with_timestamps_audio_request_accept.py | 7 + src/speechify/client.py | 27 +- src/speechify/core/client_wrapper.py | 4 +- src/speechify/errors/__init__.py | 3 + .../errors/content_too_large_error.py | 11 + src/speechify/models/__init__.py | 4 + src/speechify/models/client.py | 118 ++++ src/speechify/models/raw_client.py | 233 ++++++++ src/speechify/types/__init__.py | 15 + src/speechify/types/consent_challenge.py | 40 ++ src/speechify/types/error.py | 4 +- src/speechify/types/error_code.py | 18 + src/speechify/types/error_detail.py | 8 + src/speechify/types/get_stream_request.py | 53 ++ .../types/get_stream_request_model.py | 0 src/speechify/types/model.py | 94 ++++ src/speechify/types/models_response.py | 37 ++ src/speechify/voices/__init__.py | 12 +- src/speechify/voices/client.py | 204 +++++-- .../voices/consent_challenges/__init__.py | 4 + .../voices/consent_challenges/client.py | 181 +++++++ .../voices/consent_challenges/raw_client.py | 392 ++++++++++++++ src/speechify/voices/raw_client.py | 165 +++++- src/speechify/voices/types/__init__.py | 10 +- .../types/list_voices_request_gender.py | 5 + .../voices/types/list_voices_request_type.py | 5 + 38 files changed, 2989 insertions(+), 544 deletions(-) delete mode 100644 .github/workflows/manual-publish.yml delete mode 100644 AGENTS.md create mode 100644 src/speechify/audio/types/stream_with_timestamps_audio_request_accept.py create mode 100644 src/speechify/errors/content_too_large_error.py create mode 100644 src/speechify/models/__init__.py create mode 100644 src/speechify/models/client.py create mode 100644 src/speechify/models/raw_client.py create mode 100644 src/speechify/types/consent_challenge.py create mode 100644 src/speechify/types/get_stream_request.py rename src/speechify/{audio => }/types/get_stream_request_model.py (100%) create mode 100644 src/speechify/types/model.py create mode 100644 src/speechify/types/models_response.py create mode 100644 src/speechify/voices/consent_challenges/__init__.py create mode 100644 src/speechify/voices/consent_challenges/client.py create mode 100644 src/speechify/voices/consent_challenges/raw_client.py create mode 100644 src/speechify/voices/types/list_voices_request_gender.py create mode 100644 src/speechify/voices/types/list_voices_request_type.py diff --git a/.fern/metadata.json b/.fern/metadata.json index baf0338..0d7c7d1 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -9,9 +9,9 @@ }, "pyproject_python_version": ">=3.9" }, - "originGitCommit": "9f50f669dbb3d0db633a6d904db8a5077b94764f", - "originGitCommitIsDirty": true, + "originGitCommit": "10a6db0bf8fe7358d20861af81ae01b0c177f5f0", + "originGitCommitIsDirty": false, "invokedBy": "ci", - "ciProvider": "unknown", - "sdkVersion": "2.0.1" + "ciProvider": "github", + "sdkVersion": "3.0.2" } \ No newline at end of file diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml deleted file mode 100644 index c914ea4..0000000 --- a/.github/workflows/manual-publish.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: manual-publish - -# Manual, one-off publisher for an already-tagged release whose automatic -# publish did not run (or ran with wrong metadata). Checks out an explicit -# tag/ref, asserts the built version matches, then publishes to PyPI. -on: - workflow_dispatch: - inputs: - ref: - description: "Git tag/ref to publish (e.g. 3.0.0)" - required: true - default: "3.0.0" - expected_version: - description: "Version the build MUST produce (guard against stale metadata)" - required: true - default: "3.0.0" - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout ref - uses: actions/checkout@v4 - with: - ref: ${{ inputs.ref }} - - name: Set up python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Bootstrap poetry - run: curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 - - name: Build - run: | - rm -rf dist - poetry build - - name: Assert built version matches expected - run: | - set -euo pipefail - test -f "dist/speechify_api-${{ inputs.expected_version }}.tar.gz" \ - || { echo "::error::expected dist/speechify_api-${{ inputs.expected_version }}.tar.gz not found"; ls -la dist; exit 1; } - test -f "dist/speechify_api-${{ inputs.expected_version }}-py3-none-any.whl" \ - || { echo "::error::expected wheel for ${{ inputs.expected_version }} not found"; ls -la dist; exit 1; } - - name: Publish to PyPI - run: | - poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} - poetry publish diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8e1c91b..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,90 +0,0 @@ -# Agent instructions — speechify-api (Python SDK) - -This is a **Fern-generated** SDK. It is published to **PyPI** as `speechify-api`. -The source of truth for the API surface lives in the `SpeechifyInc/speechify-api` -repo (`fern/`); this repo receives generated code on the `sdk-release` branch. - -**Read this before doing anything that touches a release.** A botched release here -publishes to an immutable public registry and breaks the contract with every -customer who installs the package. This file exists because we already did that -once — see "Postmortem" below. - -## The golden rule - -**Publishing to PyPI is IRREVERSIBLE.** A version, once uploaded, can never be -overwritten or reused — only *yanked* (hidden from resolution), and yanking -requires a human web session (there is no token/API/CI path for it). - -Never trigger a publish until you have, in order: - -1. **Audited the release plumbing** (this file, the workflow, the config). -2. **Confirmed the version is correct in EVERY version-bearing file** (see checklist). -3. **Dry-run built and asserted the artifact version** matches the intended tag. -4. **Got explicit human sign-off** for the publish itself. - -Do NOT admin-merge release PRs to force a publish. Do NOT bypass required reviews. - -## Version-surface checklist — the mistake we keep making - -The #1 failure mode is **a generated version string not getting bumped**, so the -package publishes under the wrong number (or reports a false version to the API). -A single stale string is a published contract break, not a typo. - -When any version changes, **grep the whole tree and confirm ALL of these agree** -before release. Never check one and assume the rest: - -- `.release-please-manifest.json` → `"."` -- `pyproject.toml` → `[project].version` **and** `[tool.poetry].version` (both!) -- `src/speechify/core/client_wrapper.py` → `User-Agent` **and** `X-Fern-SDK-Version` -- the git tag created for the release -- the version in the built artifact filename (`speechify_api-X.Y.Z-*.whl`) - -Fast audit: - -```bash -grep -rnE '[0-9]+\.[0-9]+\.[0-9]+' \ - .release-please-manifest.json pyproject.toml \ - src/speechify/core/client_wrapper.py | grep -v '<3.0.0' -``` - -`X-Fern-SDK-Version` / `User-Agent` are sent on every request. If they lie, your -telemetry, version-gating, and support debugging are all wrong for that release. - -## Known plumbing traps - -- **`pyproject.toml` version wiring.** release-please's Python updater **skips - `pyproject.toml` when `[project].dynamic = ["version"]`**. If the version is - dynamic and `extra-files` does not target it, `poetry build` builds a STALE - version while the tag/manifest say something else. Keep the version **static** - in `[project].version` and ensure `release-please-config.json` `extra-files` - bumps it. (This is exactly how we shipped `2.0.1` under a `3.0.0` tag.) -- **`type: "generic"` extra-files no-op silently** unless the target line carries - an `x-release-please-version` marker comment. Fern regenerates - `client_wrapper.py` each run, so prefer the `toml` updaters on `pyproject.toml` - as the real source of truth; do not rely on the generic updater alone. -- The publish job trusts whatever `poetry build` produces. There is a - `manual-publish.yml` workflow that asserts the built version before uploading — - prefer it for any one-off republish, and keep the assert in the automatic path. - -## If a release has already gone wrong - -- **Wrong version on PyPI:** it is permanent. Fix the version wiring, cut a NEW - correct version, and **yank** the bad one (human, via - `https://pypi.org/manage/project/speechify-api/release//`). -- **Tag points at a bad commit:** the tag/GitHub release can be recreated on the - corrected commit (public history mutation — back up the old SHA + notes first). -- Never delete a PyPI release to "reuse" the number. You can't. - -## Postmortem — the 3.0.0 release (why this file exists) - -A routine regeneration was pushed straight to publish. Failures, in order: - -1. Breaking-change PRs were admin-merged past the review gate. -2. release-please tagged `3.0.0`, but `poetry build` used a stale `pyproject.toml` - version → **`speechify-api 2.0.1` was published to PyPI** (immutable), carrying - the 3.0.0 breaking code under a patch number. -3. Each stale version string was found reactively, one at a time, instead of via - one exhaustive version-surface audit. - -Lesson: **audit the whole release surface first, dry-run, get sign-off, then -publish.** Treat one stale version string as a signal to check every other one. diff --git a/README.md b/README.md index f15bb1a..e15d1df 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Speechify Python Library -[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2Fspeechifyinc%2Fspeechify-api-sdk-python) +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2Fspeechify-ai%2Fsdk-python) [![pypi](https://img.shields.io/pypi/v/speechify-api)](https://pypi.python.org/pypi/speechify-api) The Speechify Python library provides convenient access to the Speechify APIs from Python. @@ -14,6 +14,7 @@ The Speechify Python library provides convenient access to the Speechify APIs fr - [Environments](#environments) - [Async Client](#async-client) - [Exception Handling](#exception-handling) +- [Streaming](#streaming) - [Pagination](#pagination) - [Advanced](#advanced) - [Access Raw Response Data](#access-raw-response-data) @@ -34,7 +35,7 @@ pip install speechify-api ## Reference -A full reference for this library is available [here](https://github.com/speechifyinc/speechify-api-sdk-python/blob/HEAD/./reference.md). +A full reference for this library is available [here](https://github.com/speechify-ai/sdk-python/blob/HEAD/./reference.md). ## Usage @@ -50,8 +51,8 @@ client = Speechify( client.audio.speech( audio_format="mp3", input="Hello! This is the Speechify text-to-speech API.", - model="simba-english", - voice_id="george", + model="simba-3.2", + voice_id="geffen_32", ) ``` @@ -86,8 +87,8 @@ async def main() -> None: await client.audio.speech( audio_format="mp3", input="Hello! This is the Speechify text-to-speech API.", - model="simba-english", - voice_id="george", + model="simba-3.2", + voice_id="geffen_32", ) @@ -109,6 +110,24 @@ except ApiError as e: print(e.body) ``` +## Streaming + +The SDK supports streaming responses, as well, the response will be a generator that you can loop over. + +```python +from speechify import Speechify + +client = Speechify( + token="", +) + +client.audio.stream_with_timestamps( + input="Streaming long-form audio with the Speechify API.", + model="simba-3.2", + voice_id="geffen_32", +) +``` + ## Pagination Paginated requests will return a `SyncPager` or `AsyncPager`, which can be used as generators for the underlying object. @@ -120,7 +139,10 @@ client = Speechify( token="", ) -client.voices.list() +client.voices.list( + locale="en", + model="simba-3.2", +) ``` ```python diff --git a/poetry.lock b/poetry.lock index a7caaa7..8e768cf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -15,132 +15,132 @@ files = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" description = "Async http client/server framework (asyncio)" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"aiohttp\"" files = [ - {file = "aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e"}, - {file = "aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491"}, - {file = "aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966"}, - {file = "aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6"}, - {file = "aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df"}, - {file = "aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c"}, - {file = "aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2"}, - {file = "aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f"}, - {file = "aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3"}, - {file = "aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126"}, - {file = "aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5"}, - {file = "aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b"}, - {file = "aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480"}, - {file = "aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d"}, - {file = "aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3"}, - {file = "aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4"}, - {file = "aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271"}, - {file = "aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847"}, - {file = "aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178"}, - {file = "aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf"}, - {file = "aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd"}, - {file = "aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe"}, - {file = "aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4"}, - {file = "aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876"}, - {file = "aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da"}, - {file = "aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588"}, - {file = "aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780"}, - {file = "aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a"}, - {file = "aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8"}, - {file = "aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15"}, - {file = "aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8"}, - {file = "aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3"}, - {file = "aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba"}, - {file = "aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397"}, - {file = "aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448"}, - {file = "aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004"}, - {file = "aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444"}, - {file = "aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0"}, - {file = "aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719"}, - {file = "aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec"}, - {file = "aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2"}, - {file = "aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340"}, - {file = "aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c"}, - {file = "aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365"}, - {file = "aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9"}, - {file = "aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6"}, - {file = "aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32"}, + {file = "aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e"}, + {file = "aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c"}, + {file = "aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7"}, + {file = "aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa"}, + {file = "aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d"}, + {file = "aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19"}, + {file = "aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559"}, + {file = "aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a"}, + {file = "aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c"}, + {file = "aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86"}, + {file = "aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71"}, + {file = "aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0"}, + {file = "aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883"}, + {file = "aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2"}, + {file = "aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062"}, + {file = "aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf"}, + {file = "aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da"}, + {file = "aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100"}, + {file = "aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7"}, + {file = "aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc"}, ] [package.dependencies] @@ -176,26 +176,26 @@ typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" description = "Reusable constraint types to use with typing.Annotated" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.2" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, - {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, + {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, + {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, ] [package.dependencies] @@ -247,14 +247,14 @@ files = [ [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, - {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] [[package]] @@ -776,14 +776,14 @@ files = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, - {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, ] [[package]] @@ -1285,14 +1285,14 @@ files = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20260518" +version = "2.9.0.20260807" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "types_python_dateutil-2.9.0.20260518-py3-none-any.whl", hash = "sha256:d6a9c5bd0de61460c8fdef8ab2b400f956a1a1075cce08d4e2b4434e478c50b8"}, - {file = "types_python_dateutil-2.9.0.20260518.tar.gz", hash = "sha256:51f02dc03b61c7f6a07df45797d4dfe8a1aa47f0b7db9ad89f6fd3a1a70e1b51"}, + {file = "types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db"}, + {file = "types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034"}, ] [[package]] @@ -1309,18 +1309,18 @@ files = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" description = "Runtime typing introspection tools" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, ] [package.dependencies] -typing-extensions = ">=4.12.0" +typing-extensions = ">=4.15.0" [[package]] name = "urllib3" @@ -1342,117 +1342,117 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "yarl" -version = "1.24.2" +version = "1.24.5" description = "Yet another URL library" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"aiohttp\"" files = [ - {file = "yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12"}, - {file = "yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0"}, - {file = "yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342"}, - {file = "yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4"}, - {file = "yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39"}, - {file = "yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1"}, - {file = "yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c"}, - {file = "yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5"}, - {file = "yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45"}, - {file = "yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122"}, - {file = "yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8"}, - {file = "yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2"}, - {file = "yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1"}, - {file = "yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad"}, - {file = "yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30"}, - {file = "yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536"}, - {file = "yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607"}, - {file = "yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992"}, - {file = "yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656"}, - {file = "yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461"}, - {file = "yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2"}, - {file = "yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630"}, - {file = "yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8"}, - {file = "yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0"}, - {file = "yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57"}, - {file = "yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b"}, - {file = "yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761"}, - {file = "yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd"}, - {file = "yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215"}, - {file = "yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d"}, - {file = "yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9"}, - {file = "yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8"}, + {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, + {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, + {file = "yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d"}, + {file = "yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224"}, + {file = "yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13"}, + {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3"}, + {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a"}, + {file = "yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd"}, + {file = "yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25"}, + {file = "yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a"}, + {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d"}, + {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec"}, + {file = "yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba"}, + {file = "yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b"}, + {file = "yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c"}, + {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2"}, + {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb"}, + {file = "yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4"}, + {file = "yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740"}, + {file = "yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1"}, + {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76"}, + {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d"}, + {file = "yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4"}, + {file = "yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad"}, + {file = "yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f"}, + {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88"}, + {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba"}, + {file = "yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047"}, + {file = "yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104"}, + {file = "yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688"}, + {file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"}, + {file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 169349c..7a17e96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "speechify-api" -version = "3.0.1" +dynamic = ["version"] [tool.poetry] name = "speechify-api" -version = "3.0.1" +version = "3.0.2" description = "Official Speechify API SDK" readme = "README.md" authors = [ @@ -44,7 +44,7 @@ packages = [ [tool.poetry.urls] Documentation = 'https://docs.speechify.ai/api-reference' Homepage = 'https://docs.speechify.ai' -Repository = 'https://github.com/speechifyinc/speechify-api-sdk-python' +Repository = 'https://github.com/speechify-ai/sdk-python' [tool.poetry.dependencies] python = "^3.10" diff --git a/reference.md b/reference.md index 95b946a..892c6ff 100644 --- a/reference.md +++ b/reference.md @@ -42,8 +42,8 @@ client = Speechify( client.audio.speech( audio_format="mp3", input="Hello! This is the Speechify text-to-speech API.", - model="simba-english", - voice_id="george", + model="simba-3.2", + voice_id="geffen_32", ) ``` @@ -99,7 +99,7 @@ Please refer to the list of the supported languages and recommendations regardin
-**model:** `typing.Optional[GetSpeechRequestModel]` — Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. +**model:** `typing.Optional[GetSpeechRequestModel]` — Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility.
@@ -195,19 +195,7 @@ client.audio.stream(
-**input:** `str` - -Plain text or SSML to be synthesized to speech. -Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. -Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody - -
-
- -
-
- -**voice_id:** `str` — Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices +**request:** `GetStreamRequest`
@@ -231,18 +219,98 @@ body instead; it takes precedence over this header.
-**language:** `typing.Optional[str]` - -Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. -Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration.
+ + + + + + + + +
client.audio.stream_with_timestamps(...) -> typing.Iterator[bytes] +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Synthesize speech and stream it back together with word-level speech +marks, for text highlighting, captions and audio-text synchronization +while the audio is still arriving. + +The response is a Server-Sent Events stream. Each `speech.chunk` event +carries a Base64-encoded run of audio, the speech marks that became +final with it, or both - a chunk may carry only one of the two, and the +last chunk of a stream is often marks-only. A terminal `speech.done` +event ends the stream; there is no `[DONE]` sentinel. Ignore any event +type you do not recognize, so that new event types do not break your +integration. + +Speech-mark times are absolute milliseconds from the start of the +synthesis, so concatenate the audio chunks into one stream and apply the +marks against that single timeline. Which chunk a mark arrives on is a +delivery detail and carries no meaning. Times stay correct for every +`output_format`: changing the codec or sample rate does not change the +duration. + +Speech marks are produced by the streaming-native models. The default +`simba-3.0` and `simba-3.2` both serve this route; the legacy +`simba-english` and `simba-multilingual` models return 400 +`speech_marks_unsupported` here. +For Base64-encoded audio and speech marks in one non-streamed JSON +response, on any model, use POST /v1/audio/speech. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from speechify import Speechify +from speechify.environment import SpeechifyEnvironment + +client = Speechify( + token="", + environment=SpeechifyEnvironment.DEFAULT, +) + +client.audio.stream_with_timestamps( + input="Streaming long-form audio with the Speechify API.", + model="simba-3.2", + voice_id="geffen_32", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
-**model:** `typing.Optional[GetStreamRequestModel]` — Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. +**request:** `GetStreamRequest`
@@ -250,7 +318,12 @@ Please refer to the list of the supported languages and recommendations regardin
-**options:** `typing.Optional[GetStreamOptionsRequest]` +**accept:** `typing.Optional[StreamWithTimestampsAudioRequestAccept]` + +Selects the audio container/codec carried inside the events when +`output_format` is not set in the request body. The selected media +type is echoed on the `Speechify-Audio-Content-Type` response +header, since the response's own Content-Type is `text/event-stream`.
@@ -258,10 +331,74 @@ Please refer to the list of the supported languages and recommendations regardin
-**output_format:** `typing.Optional[AudioStreamOutputFormat]` — The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration.
+
+
+ + +
+
+
+ +## models +
client.models.list() -> ModelsResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List the text-to-speech models available for synthesis. Drive a model +picker from this response, then pass a model `id` as the `model` +parameter to POST /v1/audio/speech or /v1/audio/stream. The response +marks the default model (used when a request omits `model`), the +routes each model may be passed to, and which voices it accepts. +Multi-speaker models arrive in a separate `dialogue_models` array +because they are valid only on POST /v1/audio/dialogue. Returns +the full set in a single response: the model catalog is static +platform reference data, so it is intentionally not paginated. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from speechify import Speechify +from speechify.environment import SpeechifyEnvironment + +client = Speechify( + token="", + environment=SpeechifyEnvironment.DEFAULT, +) + +client.models.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
@@ -292,11 +429,13 @@ Please refer to the list of the supported languages and recommendations regardin
Lists the voices available to the caller - the shared voice -catalog plus the workspace's personal cloned voices. By default +catalog plus the workspace's cloned voices, whichever member or +service-account key created them. By default the full catalogue is returned in one response. Pagination is opt-in: pass `limit` (and then `cursor` from the previous response) to page through the list while `has_more` is true. Max -page size is 200. +page size is 200. Narrow the list with the `type` and `locale` +filters (applied before pagination, so pages stay full).
@@ -319,7 +458,10 @@ client = Speechify( environment=SpeechifyEnvironment.DEFAULT, ) -client.voices.list() +client.voices.list( + locale="en", + model="simba-3.2", +) ```
@@ -351,6 +493,48 @@ client.voices.list()
+**type:** `typing.Optional[ListVoicesRequestType]` + +Filter by voice type: `personal` (the workspace's cloned voices) +or `shared` (the public catalogue). Omit to return both. + +
+
+ +
+
+ +**locale:** `typing.Optional[str]` + +Filter to voices whose locale matches this BCP-47 language range, +prefix-matched: `en` matches `en-US` and `en-GB`; `en-US` matches +only `en-US`. Case-insensitive. Omit to return all locales. + +
+
+ +
+
+ +**gender:** `typing.Optional[ListVoicesRequestGender]` — Filter by voice gender. Omit to return all genders. + +
+
+ +
+
+ +**model:** `typing.Optional[str]` + +Filter to voices that support this model (as listed in each voice's +`models[]`), e.g. `simba-3.2`. Omit to return voices for all models. + +
+
+ +
+
+ **request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration.
@@ -375,7 +559,13 @@ client.voices.list()
-Create a personal (cloned) voice for the user +Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + +Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + +The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + +Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog.
@@ -402,9 +592,10 @@ client.voices.create( idempotency_key="a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", sample="example_sample", avatar="example_avatar", + consent_recording="example_consent_recording", name="name", gender="male", - consent="consent", + consent_challenge_id="consent_challenge_id", ) ``` @@ -442,7 +633,7 @@ not_specified GenderNotSpecified
-**sample:** `core.File` — Audio sample file +**sample:** `core.File` — Audio sample of the voice to clone, 10-30 seconds of clean speech.
@@ -450,11 +641,26 @@ not_specified GenderNotSpecified
-**consent:** `str` +**consent_challenge_id:** `str` -A **string** representing the user consent information in JSON format -This should include the fullName and email of the consenting individual. -For example, `{"fullName": "John Doe", "email": "john@example.com"}` +The `id` of the consent challenge this create consumes, from +`POST /v1/voices/consent-challenges`. Single use: once a +create has consumed it, whether or not that create +succeeded, it cannot be used again. + +
+
+ +
+
+ +**consent_recording:** `core.File` + +Recording of the speaker reading the challenge's `phrase` +aloud. This is the consent record for the voice, not a +second voice sample: it must be the same person as in +`sample`, and it is retained as evidence. 5-30 seconds, at +most 25 MB, in any common audio container.
@@ -518,9 +724,9 @@ response carries the `Idempotent-Replayed: true` header.
Fetch a single voice by id - a shared catalogue voice or one of -the caller's own personal (cloned) voices. A personal voice that -belongs to another workspace returns 404, identical to an -unknown id, so voice inventory is never enumerable across tenants. +the workspace's cloned voices. A cloned voice that belongs to +another workspace returns 404, identical to an unknown id, so +voice inventory is never enumerable across tenants.
@@ -593,7 +799,9 @@ client.voices.get(
-Delete a personal (cloned) voice +Delete one of the workspace's cloned voices. Requires the +`content.manage` permission (owner, admin, or member); a +service-account key is authorized by its scopes instead.
@@ -727,3 +935,111 @@ client.voices.download_sample(
+## Voices ConsentChallenges +
client.voices.consent_challenges.create(...) -> ConsentChallenge +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Start the consent check for a voice clone. + +Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + +A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + +Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from speechify import Speechify +from speechify.environment import SpeechifyEnvironment + +client = Speechify( + token="", + environment=SpeechifyEnvironment.DEFAULT, +) + +client.voices.consent_challenges.create( + idempotency_key="a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + full_name="Jane Doe", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**full_name:** `str` + +Full name of the person consenting to have their voice cloned. +Speechify binds it to the challenge and stores it with the consent +record, so the create that consumes the challenge does not carry it +and cannot change it. + +At most 120 bytes once UTF-8 encoded, which is 120 characters of +Latin script but around 40 of Chinese, Japanese or Korean. Stated in +bytes rather than as a `maxLength` because the two only agree on +single-byte scripts, and a character count that never over-accepts +would have to refuse Latin names at 30. A name over the limit comes +back as `validation_failed` reporting its measured length. + +
+
+ +
+
+ +**idempotency_key:** `typing.Optional[str]` + +A client-generated key (an opaque string, max 255 chars) that makes a +side-effect POST safe to retry: the server runs the operation exactly +once and replays the first response (its status and body) for 24 hours. +Reusing a key with a different request body, or while the first request +is still in flight, returns `409 idempotency_conflict`. A replayed +response carries the `Idempotent-Replayed: true` header. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ diff --git a/src/speechify/__init__.py b/src/speechify/__init__.py index fd29896..dc621df 100644 --- a/src/speechify/__init__.py +++ b/src/speechify/__init__.py @@ -9,6 +9,7 @@ from .types import ( AudioOutputFormat, AudioStreamOutputFormat, + ConsentChallenge, Error, ErrorCode, ErrorDetail, @@ -16,6 +17,8 @@ GetSpeechResponse, GetSpeechResponseAudioFormat, GetStreamOptionsRequest, + GetStreamRequest, + GetStreamRequestModel, GetVoice, GetVoiceGender, GetVoiceLanguage, @@ -23,6 +26,8 @@ GetVoicesModel, GetVoicesModelName, ListVoicesResponse, + Model, + ModelsResponse, NestedChunk, PaginationMeta, SpeechMarks, @@ -31,6 +36,7 @@ BadGatewayError, BadRequestError, ConflictError, + ContentTooLargeError, ForbiddenError, InternalServerError, NotFoundError, @@ -40,18 +46,18 @@ UnauthorizedError, UnprocessableEntityError, ) - from . import audio, voices + from . import audio, models, voices from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient from .audio import ( GetSpeechRequestAudioFormat, GetSpeechRequestModel, - GetStreamRequestModel, StreamAudioRequestAccept, + StreamWithTimestampsAudioRequestAccept, ) from .client import AsyncSpeechify, Speechify from .environment import SpeechifyEnvironment from .version import __version__ - from .voices import CreateVoicesRequestGender + from .voices import CreateVoicesRequestGender, ListVoicesRequestGender, ListVoicesRequestType _dynamic_imports: typing.Dict[str, str] = { "AsyncSpeechify": ".client", "AudioOutputFormat": ".types", @@ -59,6 +65,8 @@ "BadGatewayError": ".errors", "BadRequestError": ".errors", "ConflictError": ".errors", + "ConsentChallenge": ".types", + "ContentTooLargeError": ".errors", "CreateVoicesRequestGender": ".voices", "DefaultAioHttpClient": "._default_clients", "DefaultAsyncHttpxClient": "._default_clients", @@ -72,7 +80,8 @@ "GetSpeechResponse": ".types", "GetSpeechResponseAudioFormat": ".types", "GetStreamOptionsRequest": ".types", - "GetStreamRequestModel": ".audio", + "GetStreamRequest": ".types", + "GetStreamRequestModel": ".types", "GetVoice": ".types", "GetVoiceGender": ".types", "GetVoiceLanguage": ".types", @@ -80,7 +89,11 @@ "GetVoicesModel": ".types", "GetVoicesModelName": ".types", "InternalServerError": ".errors", + "ListVoicesRequestGender": ".voices", + "ListVoicesRequestType": ".voices", "ListVoicesResponse": ".types", + "Model": ".types", + "ModelsResponse": ".types", "NestedChunk": ".types", "NotFoundError": ".errors", "PaginationMeta": ".types", @@ -90,11 +103,13 @@ "Speechify": ".client", "SpeechifyEnvironment": ".environment", "StreamAudioRequestAccept": ".audio", + "StreamWithTimestampsAudioRequestAccept": ".audio", "TooManyRequestsError": ".errors", "UnauthorizedError": ".errors", "UnprocessableEntityError": ".errors", "__version__": ".version", "audio": ".audio", + "models": ".models", "voices": ".voices", } @@ -127,6 +142,8 @@ def __dir__(): "BadGatewayError", "BadRequestError", "ConflictError", + "ConsentChallenge", + "ContentTooLargeError", "CreateVoicesRequestGender", "DefaultAioHttpClient", "DefaultAsyncHttpxClient", @@ -140,6 +157,7 @@ def __dir__(): "GetSpeechResponse", "GetSpeechResponseAudioFormat", "GetStreamOptionsRequest", + "GetStreamRequest", "GetStreamRequestModel", "GetVoice", "GetVoiceGender", @@ -148,7 +166,11 @@ def __dir__(): "GetVoicesModel", "GetVoicesModelName", "InternalServerError", + "ListVoicesRequestGender", + "ListVoicesRequestType", "ListVoicesResponse", + "Model", + "ModelsResponse", "NestedChunk", "NotFoundError", "PaginationMeta", @@ -158,10 +180,12 @@ def __dir__(): "Speechify", "SpeechifyEnvironment", "StreamAudioRequestAccept", + "StreamWithTimestampsAudioRequestAccept", "TooManyRequestsError", "UnauthorizedError", "UnprocessableEntityError", "__version__", "audio", + "models", "voices", ] diff --git a/src/speechify/audio/__init__.py b/src/speechify/audio/__init__.py index 3167496..ebbb090 100644 --- a/src/speechify/audio/__init__.py +++ b/src/speechify/audio/__init__.py @@ -9,14 +9,14 @@ from .types import ( GetSpeechRequestAudioFormat, GetSpeechRequestModel, - GetStreamRequestModel, StreamAudioRequestAccept, + StreamWithTimestampsAudioRequestAccept, ) _dynamic_imports: typing.Dict[str, str] = { "GetSpeechRequestAudioFormat": ".types", "GetSpeechRequestModel": ".types", - "GetStreamRequestModel": ".types", "StreamAudioRequestAccept": ".types", + "StreamWithTimestampsAudioRequestAccept": ".types", } @@ -41,4 +41,9 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["GetSpeechRequestAudioFormat", "GetSpeechRequestModel", "GetStreamRequestModel", "StreamAudioRequestAccept"] +__all__ = [ + "GetSpeechRequestAudioFormat", + "GetSpeechRequestModel", + "StreamAudioRequestAccept", + "StreamWithTimestampsAudioRequestAccept", +] diff --git a/src/speechify/audio/client.py b/src/speechify/audio/client.py index aecb9e1..314d94c 100644 --- a/src/speechify/audio/client.py +++ b/src/speechify/audio/client.py @@ -9,11 +9,12 @@ from ..types.get_speech_options_request import GetSpeechOptionsRequest from ..types.get_speech_response import GetSpeechResponse from ..types.get_stream_options_request import GetStreamOptionsRequest +from ..types.get_stream_request_model import GetStreamRequestModel from .raw_client import AsyncRawAudioClient, RawAudioClient from .types.get_speech_request_audio_format import GetSpeechRequestAudioFormat from .types.get_speech_request_model import GetSpeechRequestModel -from .types.get_stream_request_model import GetStreamRequestModel from .types.stream_audio_request_accept import StreamAudioRequestAccept +from .types.stream_with_timestamps_audio_request_accept import StreamWithTimestampsAudioRequestAccept # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -71,7 +72,7 @@ def speech( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetSpeechRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetSpeechOptionsRequest] @@ -91,14 +92,14 @@ def speech( from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) client.audio.speech( audio_format="mp3", input="Hello! This is the Speechify text-to-speech API.", - model="simba-english", - voice_id="george", + model="simba-3.2", + voice_id="geffen_32", ) """ _response = self._raw_client.speech( @@ -158,7 +159,7 @@ def stream( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetStreamRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetStreamOptionsRequest] @@ -182,7 +183,7 @@ def stream( from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) client.audio.stream( @@ -202,6 +203,112 @@ def stream( ) as r: yield from r.data + def stream_with_timestamps( + self, + *, + input: str, + voice_id: str, + accept: typing.Optional[StreamWithTimestampsAudioRequestAccept] = None, + language: typing.Optional[str] = OMIT, + model: typing.Optional[GetStreamRequestModel] = OMIT, + options: typing.Optional[GetStreamOptionsRequest] = OMIT, + output_format: typing.Optional[AudioStreamOutputFormat] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> typing.Iterator[str]: + """ + Synthesize speech and stream it back together with word-level speech + marks, for text highlighting, captions and audio-text synchronization + while the audio is still arriving. + + The response is a Server-Sent Events stream. Each `speech.chunk` event + carries a Base64-encoded run of audio, the speech marks that became + final with it, or both - a chunk may carry only one of the two, and the + last chunk of a stream is often marks-only. A terminal `speech.done` + event ends the stream; there is no `[DONE]` sentinel. Ignore any event + type you do not recognize, so that new event types do not break your + integration. + + Speech-mark times are absolute milliseconds from the start of the + synthesis, so concatenate the audio chunks into one stream and apply the + marks against that single timeline. Which chunk a mark arrives on is a + delivery detail and carries no meaning. Times stay correct for every + `output_format`: changing the codec or sample rate does not change the + duration. + + Speech marks are produced by the streaming-native models. The default + `simba-3.0` and `simba-3.2` both serve this route; the legacy + `simba-english` and `simba-multilingual` models return 400 + `speech_marks_unsupported` here. + For Base64-encoded audio and speech marks in one non-streamed JSON + response, on any model, use POST /v1/audio/speech. + + Parameters + ---------- + input : str + Plain text or SSML to be synthesized to speech. + Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. + Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody + + voice_id : str + Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices + + accept : typing.Optional[StreamWithTimestampsAudioRequestAccept] + Selects the audio container/codec carried inside the events when + `output_format` is not set in the request body. The selected media + type is echoed on the `Speechify-Audio-Content-Type` response + header, since the response's own Content-Type is `text/event-stream`. + + language : typing.Optional[str] + Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. + Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. + + model : typing.Optional[GetStreamRequestModel] + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. + + options : typing.Optional[GetStreamOptionsRequest] + + output_format : typing.Optional[AudioStreamOutputFormat] + The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Yields + ------ + typing.Iterator[str] + A Server-Sent Events stream of `speech.chunk` events followed by one + terminal `speech.done` event. A failure after the stream has started + is delivered as a `speech.error` event carrying the standard error + envelope, because the status code is already committed. + + Examples + -------- + from speechify import Speechify + + client = Speechify( + "2026-09-13", + token="YOUR_TOKEN", + ) + response = client.audio.stream_with_timestamps( + input="Streaming long-form audio with the Speechify API.", + model="simba-3.2", + voice_id="geffen_32", + ) + for chunk in response: + yield chunk + """ + with self._raw_client.stream_with_timestamps( + input=input, + voice_id=voice_id, + accept=accept, + language=language, + model=model, + options=options, + output_format=output_format, + request_options=request_options, + ) as r: + yield from r.data + class AsyncAudioClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -255,7 +362,7 @@ async def speech( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetSpeechRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetSpeechOptionsRequest] @@ -277,7 +384,7 @@ async def speech( from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) @@ -286,8 +393,8 @@ async def main() -> None: await client.audio.speech( audio_format="mp3", input="Hello! This is the Speechify text-to-speech API.", - model="simba-english", - voice_id="george", + model="simba-3.2", + voice_id="geffen_32", ) @@ -350,7 +457,7 @@ async def stream( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetStreamRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetStreamOptionsRequest] @@ -376,7 +483,7 @@ async def stream( from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) @@ -402,3 +509,118 @@ async def main() -> None: ) as r: async for _chunk in r.data: yield _chunk + + async def stream_with_timestamps( + self, + *, + input: str, + voice_id: str, + accept: typing.Optional[StreamWithTimestampsAudioRequestAccept] = None, + language: typing.Optional[str] = OMIT, + model: typing.Optional[GetStreamRequestModel] = OMIT, + options: typing.Optional[GetStreamOptionsRequest] = OMIT, + output_format: typing.Optional[AudioStreamOutputFormat] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> typing.AsyncIterator[str]: + """ + Synthesize speech and stream it back together with word-level speech + marks, for text highlighting, captions and audio-text synchronization + while the audio is still arriving. + + The response is a Server-Sent Events stream. Each `speech.chunk` event + carries a Base64-encoded run of audio, the speech marks that became + final with it, or both - a chunk may carry only one of the two, and the + last chunk of a stream is often marks-only. A terminal `speech.done` + event ends the stream; there is no `[DONE]` sentinel. Ignore any event + type you do not recognize, so that new event types do not break your + integration. + + Speech-mark times are absolute milliseconds from the start of the + synthesis, so concatenate the audio chunks into one stream and apply the + marks against that single timeline. Which chunk a mark arrives on is a + delivery detail and carries no meaning. Times stay correct for every + `output_format`: changing the codec or sample rate does not change the + duration. + + Speech marks are produced by the streaming-native models. The default + `simba-3.0` and `simba-3.2` both serve this route; the legacy + `simba-english` and `simba-multilingual` models return 400 + `speech_marks_unsupported` here. + For Base64-encoded audio and speech marks in one non-streamed JSON + response, on any model, use POST /v1/audio/speech. + + Parameters + ---------- + input : str + Plain text or SSML to be synthesized to speech. + Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. + Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody + + voice_id : str + Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices + + accept : typing.Optional[StreamWithTimestampsAudioRequestAccept] + Selects the audio container/codec carried inside the events when + `output_format` is not set in the request body. The selected media + type is echoed on the `Speechify-Audio-Content-Type` response + header, since the response's own Content-Type is `text/event-stream`. + + language : typing.Optional[str] + Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. + Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. + + model : typing.Optional[GetStreamRequestModel] + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. + + options : typing.Optional[GetStreamOptionsRequest] + + output_format : typing.Optional[AudioStreamOutputFormat] + The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Yields + ------ + typing.AsyncIterator[str] + A Server-Sent Events stream of `speech.chunk` events followed by one + terminal `speech.done` event. A failure after the stream has started + is delivered as a `speech.error` event carrying the standard error + envelope, because the status code is already committed. + + Examples + -------- + import asyncio + + from speechify import AsyncSpeechify + + client = AsyncSpeechify( + "2026-09-13", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + response = await client.audio.stream_with_timestamps( + input="Streaming long-form audio with the Speechify API.", + model="simba-3.2", + voice_id="geffen_32", + ) + async for chunk in response: + yield chunk + + + asyncio.run(main()) + """ + async with self._raw_client.stream_with_timestamps( + input=input, + voice_id=voice_id, + accept=accept, + language=language, + model=model, + options=options, + output_format=output_format, + request_options=request_options, + ) as r: + async for _chunk in r.data: + yield _chunk diff --git a/src/speechify/audio/raw_client.py b/src/speechify/audio/raw_client.py index d02ac39..96103a7 100644 --- a/src/speechify/audio/raw_client.py +++ b/src/speechify/audio/raw_client.py @@ -3,12 +3,14 @@ import contextlib import typing from json.decoder import JSONDecodeError +from logging import error, warning from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.http_sse._api import EventSource from ..core.parse_error import ParsingError -from ..core.pydantic_utilities import parse_obj_as +from ..core.pydantic_utilities import parse_obj_as, parse_sse_obj from ..core.request_options import RequestOptions from ..core.serialization import convert_and_respect_annotation_metadata from ..errors.bad_gateway_error import BadGatewayError @@ -26,10 +28,11 @@ from ..types.get_speech_options_request import GetSpeechOptionsRequest from ..types.get_speech_response import GetSpeechResponse from ..types.get_stream_options_request import GetStreamOptionsRequest +from ..types.get_stream_request_model import GetStreamRequestModel from .types.get_speech_request_audio_format import GetSpeechRequestAudioFormat from .types.get_speech_request_model import GetSpeechRequestModel -from .types.get_stream_request_model import GetStreamRequestModel from .types.stream_audio_request_accept import StreamAudioRequestAccept +from .types.stream_with_timestamps_audio_request_accept import StreamWithTimestampsAudioRequestAccept from pydantic import ValidationError # this is used as the default value for optional parameters @@ -77,7 +80,7 @@ def speech( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetSpeechRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetSpeechOptionsRequest] @@ -276,7 +279,7 @@ def stream( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetStreamRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetStreamOptionsRequest] @@ -439,6 +442,252 @@ def _stream() -> HttpResponse[typing.Iterator[bytes]]: yield _stream() + @contextlib.contextmanager + def stream_with_timestamps( + self, + *, + input: str, + voice_id: str, + accept: typing.Optional[StreamWithTimestampsAudioRequestAccept] = None, + language: typing.Optional[str] = OMIT, + model: typing.Optional[GetStreamRequestModel] = OMIT, + options: typing.Optional[GetStreamOptionsRequest] = OMIT, + output_format: typing.Optional[AudioStreamOutputFormat] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> typing.Iterator[HttpResponse[typing.Iterator[str]]]: + """ + Synthesize speech and stream it back together with word-level speech + marks, for text highlighting, captions and audio-text synchronization + while the audio is still arriving. + + The response is a Server-Sent Events stream. Each `speech.chunk` event + carries a Base64-encoded run of audio, the speech marks that became + final with it, or both - a chunk may carry only one of the two, and the + last chunk of a stream is often marks-only. A terminal `speech.done` + event ends the stream; there is no `[DONE]` sentinel. Ignore any event + type you do not recognize, so that new event types do not break your + integration. + + Speech-mark times are absolute milliseconds from the start of the + synthesis, so concatenate the audio chunks into one stream and apply the + marks against that single timeline. Which chunk a mark arrives on is a + delivery detail and carries no meaning. Times stay correct for every + `output_format`: changing the codec or sample rate does not change the + duration. + + Speech marks are produced by the streaming-native models. The default + `simba-3.0` and `simba-3.2` both serve this route; the legacy + `simba-english` and `simba-multilingual` models return 400 + `speech_marks_unsupported` here. + For Base64-encoded audio and speech marks in one non-streamed JSON + response, on any model, use POST /v1/audio/speech. + + Parameters + ---------- + input : str + Plain text or SSML to be synthesized to speech. + Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. + Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody + + voice_id : str + Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices + + accept : typing.Optional[StreamWithTimestampsAudioRequestAccept] + Selects the audio container/codec carried inside the events when + `output_format` is not set in the request body. The selected media + type is echoed on the `Speechify-Audio-Content-Type` response + header, since the response's own Content-Type is `text/event-stream`. + + language : typing.Optional[str] + Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. + Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. + + model : typing.Optional[GetStreamRequestModel] + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. + + options : typing.Optional[GetStreamOptionsRequest] + + output_format : typing.Optional[AudioStreamOutputFormat] + The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Yields + ------ + typing.Iterator[HttpResponse[typing.Iterator[str]]] + A Server-Sent Events stream of `speech.chunk` events followed by one + terminal `speech.done` event. A failure after the stream has started + is delivered as a `speech.error` event carrying the standard error + envelope, because the status code is already committed. + """ + with self._client_wrapper.httpx_client.stream( + "v1/audio/stream/with-timestamps", + method="POST", + json={ + "input": input, + "language": language, + "model": model, + "options": convert_and_respect_annotation_metadata( + object_=options, annotation=GetStreamOptionsRequest, direction="write" + ), + "output_format": output_format, + "voice_id": voice_id, + }, + headers={ + "content-type": "application/json", + "Accept": str(accept) if accept is not None else None, + }, + request_options=request_options, + omit=OMIT, + ) as _response: + + def _stream() -> HttpResponse[typing.Iterator[str]]: + try: + if 200 <= _response.status_code < 300: + + def _iter(): + _event_source = EventSource(_response) + for _sse in _event_source.iter_sse(): + if _sse.data == None: + return + try: + yield typing.cast( + str, + parse_sse_obj( + sse=_sse, + type_=str, # type: ignore + ), + ) + except JSONDecodeError as e: + warning(f"Skipping SSE event with invalid JSON: {e}, sse: {_sse!r}") + except (TypeError, ValueError, KeyError, AttributeError) as e: + warning( + f"Skipping SSE event due to model construction error: {type(e).__name__}: {e}, sse: {_sse!r}" + ) + except Exception as e: + error( + f"Unexpected error processing SSE event: {type(e).__name__}: {e}, sse: {_sse!r}" + ) + return + + return HttpResponse(response=_response, data=_iter()) + _response.read() + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 402: + raise PaymentRequiredError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 502: + raise BadGatewayError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 503: + raise ServiceUnavailableError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.text + ) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.json(), + cause=e, + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + yield _stream() + class AsyncRawAudioClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -481,7 +730,7 @@ async def speech( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetSpeechRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetSpeechOptionsRequest] @@ -680,7 +929,7 @@ async def stream( Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. model : typing.Optional[GetStreamRequestModel] - Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. options : typing.Optional[GetStreamOptionsRequest] @@ -843,3 +1092,249 @@ async def _stream() -> AsyncHttpResponse[typing.AsyncIterator[bytes]]: raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) yield await _stream() + + @contextlib.asynccontextmanager + async def stream_with_timestamps( + self, + *, + input: str, + voice_id: str, + accept: typing.Optional[StreamWithTimestampsAudioRequestAccept] = None, + language: typing.Optional[str] = OMIT, + model: typing.Optional[GetStreamRequestModel] = OMIT, + options: typing.Optional[GetStreamOptionsRequest] = OMIT, + output_format: typing.Optional[AudioStreamOutputFormat] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> typing.AsyncIterator[AsyncHttpResponse[typing.AsyncIterator[str]]]: + """ + Synthesize speech and stream it back together with word-level speech + marks, for text highlighting, captions and audio-text synchronization + while the audio is still arriving. + + The response is a Server-Sent Events stream. Each `speech.chunk` event + carries a Base64-encoded run of audio, the speech marks that became + final with it, or both - a chunk may carry only one of the two, and the + last chunk of a stream is often marks-only. A terminal `speech.done` + event ends the stream; there is no `[DONE]` sentinel. Ignore any event + type you do not recognize, so that new event types do not break your + integration. + + Speech-mark times are absolute milliseconds from the start of the + synthesis, so concatenate the audio chunks into one stream and apply the + marks against that single timeline. Which chunk a mark arrives on is a + delivery detail and carries no meaning. Times stay correct for every + `output_format`: changing the codec or sample rate does not change the + duration. + + Speech marks are produced by the streaming-native models. The default + `simba-3.0` and `simba-3.2` both serve this route; the legacy + `simba-english` and `simba-multilingual` models return 400 + `speech_marks_unsupported` here. + For Base64-encoded audio and speech marks in one non-streamed JSON + response, on any model, use POST /v1/audio/speech. + + Parameters + ---------- + input : str + Plain text or SSML to be synthesized to speech. + Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. + Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody + + voice_id : str + Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices + + accept : typing.Optional[StreamWithTimestampsAudioRequestAccept] + Selects the audio container/codec carried inside the events when + `output_format` is not set in the request body. The selected media + type is echoed on the `Speechify-Audio-Content-Type` response + header, since the response's own Content-Type is `text/event-stream`. + + language : typing.Optional[str] + Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. + Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. + + model : typing.Optional[GetStreamRequestModel] + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. + + options : typing.Optional[GetStreamOptionsRequest] + + output_format : typing.Optional[AudioStreamOutputFormat] + The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Yields + ------ + typing.AsyncIterator[AsyncHttpResponse[typing.AsyncIterator[str]]] + A Server-Sent Events stream of `speech.chunk` events followed by one + terminal `speech.done` event. A failure after the stream has started + is delivered as a `speech.error` event carrying the standard error + envelope, because the status code is already committed. + """ + async with self._client_wrapper.httpx_client.stream( + "v1/audio/stream/with-timestamps", + method="POST", + json={ + "input": input, + "language": language, + "model": model, + "options": convert_and_respect_annotation_metadata( + object_=options, annotation=GetStreamOptionsRequest, direction="write" + ), + "output_format": output_format, + "voice_id": voice_id, + }, + headers={ + "content-type": "application/json", + "Accept": str(accept) if accept is not None else None, + }, + request_options=request_options, + omit=OMIT, + ) as _response: + + async def _stream() -> AsyncHttpResponse[typing.AsyncIterator[str]]: + try: + if 200 <= _response.status_code < 300: + + async def _iter(): + _event_source = EventSource(_response) + async for _sse in _event_source.aiter_sse(): + if _sse.data == None: + return + try: + yield typing.cast( + str, + parse_sse_obj( + sse=_sse, + type_=str, # type: ignore + ), + ) + except JSONDecodeError as e: + warning(f"Skipping SSE event with invalid JSON: {e}, sse: {_sse!r}") + except (TypeError, ValueError, KeyError, AttributeError) as e: + warning( + f"Skipping SSE event due to model construction error: {type(e).__name__}: {e}, sse: {_sse!r}" + ) + except Exception as e: + error( + f"Unexpected error processing SSE event: {type(e).__name__}: {e}, sse: {_sse!r}" + ) + return + + return AsyncHttpResponse(response=_response, data=_iter()) + await _response.aread() + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 402: + raise PaymentRequiredError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 502: + raise BadGatewayError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 503: + raise ServiceUnavailableError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.text + ) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.json(), + cause=e, + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + yield await _stream() diff --git a/src/speechify/audio/types/__init__.py b/src/speechify/audio/types/__init__.py index 0d1a756..73ce14e 100644 --- a/src/speechify/audio/types/__init__.py +++ b/src/speechify/audio/types/__init__.py @@ -8,13 +8,13 @@ if typing.TYPE_CHECKING: from .get_speech_request_audio_format import GetSpeechRequestAudioFormat from .get_speech_request_model import GetSpeechRequestModel - from .get_stream_request_model import GetStreamRequestModel from .stream_audio_request_accept import StreamAudioRequestAccept + from .stream_with_timestamps_audio_request_accept import StreamWithTimestampsAudioRequestAccept _dynamic_imports: typing.Dict[str, str] = { "GetSpeechRequestAudioFormat": ".get_speech_request_audio_format", "GetSpeechRequestModel": ".get_speech_request_model", - "GetStreamRequestModel": ".get_stream_request_model", "StreamAudioRequestAccept": ".stream_audio_request_accept", + "StreamWithTimestampsAudioRequestAccept": ".stream_with_timestamps_audio_request_accept", } @@ -39,4 +39,9 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["GetSpeechRequestAudioFormat", "GetSpeechRequestModel", "GetStreamRequestModel", "StreamAudioRequestAccept"] +__all__ = [ + "GetSpeechRequestAudioFormat", + "GetSpeechRequestModel", + "StreamAudioRequestAccept", + "StreamWithTimestampsAudioRequestAccept", +] diff --git a/src/speechify/audio/types/stream_with_timestamps_audio_request_accept.py b/src/speechify/audio/types/stream_with_timestamps_audio_request_accept.py new file mode 100644 index 0000000..2cc504a --- /dev/null +++ b/src/speechify/audio/types/stream_with_timestamps_audio_request_accept.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +StreamWithTimestampsAudioRequestAccept = typing.Union[ + typing.Literal["audio/mpeg", "audio/ogg", "audio/aac", "audio/pcm"], typing.Any +] diff --git a/src/speechify/client.py b/src/speechify/client.py index 2cf7eef..4c47cb4 100644 --- a/src/speechify/client.py +++ b/src/speechify/client.py @@ -13,6 +13,7 @@ if typing.TYPE_CHECKING: from .audio.client import AsyncAudioClient, AudioClient + from .models.client import AsyncModelsClient, ModelsClient from .voices.client import AsyncVoicesClient, VoicesClient @@ -59,7 +60,7 @@ class Speechify: from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) """ @@ -69,7 +70,7 @@ def __init__( *, base_url: typing.Optional[str] = None, environment: SpeechifyEnvironment = SpeechifyEnvironment.DEFAULT, - version: typing.Optional[str] = "2026-07-07", + version: typing.Optional[str] = "2026-09-13", token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = os.getenv("SPEECHIFY_API_KEY"), headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, @@ -101,6 +102,7 @@ def __init__( logging=logging, ) self._audio: typing.Optional[AudioClient] = None + self._models: typing.Optional[ModelsClient] = None self._voices: typing.Optional[VoicesClient] = None @property @@ -111,6 +113,14 @@ def audio(self): self._audio = AudioClient(client_wrapper=self._client_wrapper) return self._audio + @property + def models(self): + if self._models is None: + from .models.client import ModelsClient # noqa: E402 + + self._models = ModelsClient(client_wrapper=self._client_wrapper) + return self._models + @property def voices(self): if self._voices is None: @@ -184,7 +194,7 @@ class AsyncSpeechify: from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) """ @@ -194,7 +204,7 @@ def __init__( *, base_url: typing.Optional[str] = None, environment: SpeechifyEnvironment = SpeechifyEnvironment.DEFAULT, - version: typing.Optional[str] = "2026-07-07", + version: typing.Optional[str] = "2026-09-13", token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = os.getenv("SPEECHIFY_API_KEY"), headers: typing.Optional[typing.Dict[str, str]] = None, async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, @@ -226,6 +236,7 @@ def __init__( logging=logging, ) self._audio: typing.Optional[AsyncAudioClient] = None + self._models: typing.Optional[AsyncModelsClient] = None self._voices: typing.Optional[AsyncVoicesClient] = None @property @@ -236,6 +247,14 @@ def audio(self): self._audio = AsyncAudioClient(client_wrapper=self._client_wrapper) return self._audio + @property + def models(self): + if self._models is None: + from .models.client import AsyncModelsClient # noqa: E402 + + self._models = AsyncModelsClient(client_wrapper=self._client_wrapper) + return self._models + @property def voices(self): if self._voices is None: diff --git a/src/speechify/core/client_wrapper.py b/src/speechify/core/client_wrapper.py index 11e6103..63452dc 100644 --- a/src/speechify/core/client_wrapper.py +++ b/src/speechify/core/client_wrapper.py @@ -31,12 +31,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "speechify-api/3.0.1", + "User-Agent": "speechify-api/3.0.2", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "speechify-api", - "X-Fern-SDK-Version": "3.0.1", + "X-Fern-SDK-Version": "3.0.2", **(self.get_custom_headers() or {}), } if self._version is not None: diff --git a/src/speechify/errors/__init__.py b/src/speechify/errors/__init__.py index d620695..de3a90e 100644 --- a/src/speechify/errors/__init__.py +++ b/src/speechify/errors/__init__.py @@ -9,6 +9,7 @@ from .bad_gateway_error import BadGatewayError from .bad_request_error import BadRequestError from .conflict_error import ConflictError + from .content_too_large_error import ContentTooLargeError from .forbidden_error import ForbiddenError from .internal_server_error import InternalServerError from .not_found_error import NotFoundError @@ -21,6 +22,7 @@ "BadGatewayError": ".bad_gateway_error", "BadRequestError": ".bad_request_error", "ConflictError": ".conflict_error", + "ContentTooLargeError": ".content_too_large_error", "ForbiddenError": ".forbidden_error", "InternalServerError": ".internal_server_error", "NotFoundError": ".not_found_error", @@ -57,6 +59,7 @@ def __dir__(): "BadGatewayError", "BadRequestError", "ConflictError", + "ContentTooLargeError", "ForbiddenError", "InternalServerError", "NotFoundError", diff --git a/src/speechify/errors/content_too_large_error.py b/src/speechify/errors/content_too_large_error.py new file mode 100644 index 0000000..ee8652e --- /dev/null +++ b/src/speechify/errors/content_too_large_error.py @@ -0,0 +1,11 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class ContentTooLargeError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=413, headers=headers, body=body) diff --git a/src/speechify/models/__init__.py b/src/speechify/models/__init__.py new file mode 100644 index 0000000..5cde020 --- /dev/null +++ b/src/speechify/models/__init__.py @@ -0,0 +1,4 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + diff --git a/src/speechify/models/client.py b/src/speechify/models/client.py new file mode 100644 index 0000000..b4c204f --- /dev/null +++ b/src/speechify/models/client.py @@ -0,0 +1,118 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.models_response import ModelsResponse +from .raw_client import AsyncRawModelsClient, RawModelsClient + + +class ModelsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawModelsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawModelsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawModelsClient + """ + return self._raw_client + + def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> ModelsResponse: + """ + List the text-to-speech models available for synthesis. Drive a model + picker from this response, then pass a model `id` as the `model` + parameter to POST /v1/audio/speech or /v1/audio/stream. The response + marks the default model (used when a request omits `model`), the + routes each model may be passed to, and which voices it accepts. + Multi-speaker models arrive in a separate `dialogue_models` array + because they are valid only on POST /v1/audio/dialogue. Returns + the full set in a single response: the model catalog is static + platform reference data, so it is intentionally not paginated. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ModelsResponse + The available text-to-speech models. + + Examples + -------- + from speechify import Speechify + + client = Speechify( + "2026-09-13", + token="YOUR_TOKEN", + ) + client.models.list() + """ + _response = self._raw_client.list(request_options=request_options) + return _response.data + + +class AsyncModelsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawModelsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawModelsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawModelsClient + """ + return self._raw_client + + async def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> ModelsResponse: + """ + List the text-to-speech models available for synthesis. Drive a model + picker from this response, then pass a model `id` as the `model` + parameter to POST /v1/audio/speech or /v1/audio/stream. The response + marks the default model (used when a request omits `model`), the + routes each model may be passed to, and which voices it accepts. + Multi-speaker models arrive in a separate `dialogue_models` array + because they are valid only on POST /v1/audio/dialogue. Returns + the full set in a single response: the model catalog is static + platform reference data, so it is intentionally not paginated. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ModelsResponse + The available text-to-speech models. + + Examples + -------- + import asyncio + + from speechify import AsyncSpeechify + + client = AsyncSpeechify( + "2026-09-13", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.models.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list(request_options=request_options) + return _response.data diff --git a/src/speechify/models/raw_client.py b/src/speechify/models/raw_client.py new file mode 100644 index 0000000..a389101 --- /dev/null +++ b/src/speechify/models/raw_client.py @@ -0,0 +1,233 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.models_response import ModelsResponse +from pydantic import ValidationError + + +class RawModelsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[ModelsResponse]: + """ + List the text-to-speech models available for synthesis. Drive a model + picker from this response, then pass a model `id` as the `model` + parameter to POST /v1/audio/speech or /v1/audio/stream. The response + marks the default model (used when a request omits `model`), the + routes each model may be passed to, and which voices it accepts. + Multi-speaker models arrive in a separate `dialogue_models` array + because they are valid only on POST /v1/audio/dialogue. Returns + the full set in a single response: the model catalog is static + platform reference data, so it is intentionally not paginated. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ModelsResponse] + The available text-to-speech models. + """ + _response = self._client_wrapper.httpx_client.request( + "v1/audio/models", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ModelsResponse, + parse_obj_as( + type_=ModelsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawModelsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ModelsResponse]: + """ + List the text-to-speech models available for synthesis. Drive a model + picker from this response, then pass a model `id` as the `model` + parameter to POST /v1/audio/speech or /v1/audio/stream. The response + marks the default model (used when a request omits `model`), the + routes each model may be passed to, and which voices it accepts. + Multi-speaker models arrive in a separate `dialogue_models` array + because they are valid only on POST /v1/audio/dialogue. Returns + the full set in a single response: the model catalog is static + platform reference data, so it is intentionally not paginated. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ModelsResponse] + The available text-to-speech models. + """ + _response = await self._client_wrapper.httpx_client.request( + "v1/audio/models", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ModelsResponse, + parse_obj_as( + type_=ModelsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/speechify/types/__init__.py b/src/speechify/types/__init__.py index 9d924d4..5e5e6c7 100644 --- a/src/speechify/types/__init__.py +++ b/src/speechify/types/__init__.py @@ -8,6 +8,7 @@ if typing.TYPE_CHECKING: from .audio_output_format import AudioOutputFormat from .audio_stream_output_format import AudioStreamOutputFormat + from .consent_challenge import ConsentChallenge from .error import Error from .error_code import ErrorCode from .error_detail import ErrorDetail @@ -15,6 +16,8 @@ from .get_speech_response import GetSpeechResponse from .get_speech_response_audio_format import GetSpeechResponseAudioFormat from .get_stream_options_request import GetStreamOptionsRequest + from .get_stream_request import GetStreamRequest + from .get_stream_request_model import GetStreamRequestModel from .get_voice import GetVoice from .get_voice_gender import GetVoiceGender from .get_voice_language import GetVoiceLanguage @@ -22,12 +25,15 @@ from .get_voices_model import GetVoicesModel from .get_voices_model_name import GetVoicesModelName from .list_voices_response import ListVoicesResponse + from .model import Model + from .models_response import ModelsResponse from .nested_chunk import NestedChunk from .pagination_meta import PaginationMeta from .speech_marks import SpeechMarks _dynamic_imports: typing.Dict[str, str] = { "AudioOutputFormat": ".audio_output_format", "AudioStreamOutputFormat": ".audio_stream_output_format", + "ConsentChallenge": ".consent_challenge", "Error": ".error", "ErrorCode": ".error_code", "ErrorDetail": ".error_detail", @@ -35,6 +41,8 @@ "GetSpeechResponse": ".get_speech_response", "GetSpeechResponseAudioFormat": ".get_speech_response_audio_format", "GetStreamOptionsRequest": ".get_stream_options_request", + "GetStreamRequest": ".get_stream_request", + "GetStreamRequestModel": ".get_stream_request_model", "GetVoice": ".get_voice", "GetVoiceGender": ".get_voice_gender", "GetVoiceLanguage": ".get_voice_language", @@ -42,6 +50,8 @@ "GetVoicesModel": ".get_voices_model", "GetVoicesModelName": ".get_voices_model_name", "ListVoicesResponse": ".list_voices_response", + "Model": ".model", + "ModelsResponse": ".models_response", "NestedChunk": ".nested_chunk", "PaginationMeta": ".pagination_meta", "SpeechMarks": ".speech_marks", @@ -72,6 +82,7 @@ def __dir__(): __all__ = [ "AudioOutputFormat", "AudioStreamOutputFormat", + "ConsentChallenge", "Error", "ErrorCode", "ErrorDetail", @@ -79,6 +90,8 @@ def __dir__(): "GetSpeechResponse", "GetSpeechResponseAudioFormat", "GetStreamOptionsRequest", + "GetStreamRequest", + "GetStreamRequestModel", "GetVoice", "GetVoiceGender", "GetVoiceLanguage", @@ -86,6 +99,8 @@ def __dir__(): "GetVoicesModel", "GetVoicesModelName", "ListVoicesResponse", + "Model", + "ModelsResponse", "NestedChunk", "PaginationMeta", "SpeechMarks", diff --git a/src/speechify/types/consent_challenge.py b/src/speechify/types/consent_challenge.py new file mode 100644 index 0000000..075a7f4 --- /dev/null +++ b/src/speechify/types/consent_challenge.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class ConsentChallenge(UniversalBaseModel): + id: str = pydantic.Field() + """ + Identifier for this challenge, sent back as `consent_challenge_id` + on the create. Treat it as an opaque string - the format is not part + of the contract and will not stay stable. + """ + + phrase: str = pydantic.Field() + """ + The sentence the speaker must read aloud. Show it exactly as + returned - the recording is transcribed and matched against this + text, so re-wording, re-casing or re-punctuating it will fail the + check. + """ + + expires_at: dt.datetime = pydantic.Field() + """ + When the challenge stops being usable. This is the only authority on + the window - do not hard-code a duration. Past it, create a new + challenge and record the new phrase. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/speechify/types/error.py b/src/speechify/types/error.py index 851bdf8..23b380b 100644 --- a/src/speechify/types/error.py +++ b/src/speechify/types/error.py @@ -14,7 +14,7 @@ class Error(UniversalBaseModel): Anthropic / Stripe style: a machine-readable `error.code` for SDK consumers to switch on, a human `error.message` for UI, and an optional `error.fields` map for per-field validation - errors. `request_id` matches the `X-Request-ID` response + errors. `request_id` matches the `Speechify-Request-Id` response header and is what customers quote when filing support tickets. """ @@ -23,7 +23,7 @@ class Error(UniversalBaseModel): request_id: typing.Optional[str] = pydantic.Field(default=None) """ Server-side request identifier. Echoes the - `X-Request-ID` response header. Stable across the + `Speechify-Request-Id` response header. Stable across the request's lifetime, written to structured logs, and useful when reporting issues. """ diff --git a/src/speechify/types/error_code.py b/src/speechify/types/error_code.py index ce5e090..1a66e34 100644 --- a/src/speechify/types/error_code.py +++ b/src/speechify/types/error_code.py @@ -44,15 +44,33 @@ "phone_number_quota_reached", "batch_calls_not_included", "voice_cloning_not_included", + "consent_challenge_not_found", + "consent_challenge_expired", + "consent_challenge_already_used", + "consent_phrase_mismatch", + "consent_speaker_mismatch", + "consent_recording_unusable", + "consent_verification_unavailable", "workspace_last_owner", "workspace_last_workspace", + "account_deletion_blocked", + "workspace_free_limit", + "workspace_single_owner", "invite_email_mismatch", "invite_already_pending", "service_account_limit_reached", "service_accounts_not_in_plan", + "speech_marks_unsupported", + "too_many_voices", + "content_policy_violation", + "topup_not_in_plan", + "credit_purchase_unpaid", "tool_config_shared", "spend_cap_exceeded", "spend_budget_exceeded", + "share_link_not_found", + "share_link_exhausted", + "share_link_limit_reached", "destination_not_allowed", "international_dialing_not_enabled", ], diff --git a/src/speechify/types/error_detail.py b/src/speechify/types/error_detail.py index c697538..aaa7065 100644 --- a/src/speechify/types/error_detail.py +++ b/src/speechify/types/error_detail.py @@ -37,6 +37,14 @@ class ErrorDetail(UniversalBaseModel): it - the `code` + `message` contract is unchanged. """ + docs_url: typing.Optional[str] = pydantic.Field(default=None) + """ + Link to the documentation that resolves this class of + error, when a stable page exists. Rate and concurrency + 429s link the API limits reference, which lists each + plan's limits and how to raise them. + """ + if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 else: diff --git a/src/speechify/types/get_stream_request.py b/src/speechify/types/get_stream_request.py new file mode 100644 index 0000000..83b0b16 --- /dev/null +++ b/src/speechify/types/get_stream_request.py @@ -0,0 +1,53 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .audio_stream_output_format import AudioStreamOutputFormat +from .get_stream_options_request import GetStreamOptionsRequest +from .get_stream_request_model import GetStreamRequestModel + + +class GetStreamRequest(UniversalBaseModel): + """ + GetStreamRequest is the wrapper for request parameters to the client + """ + + input: str = pydantic.Field() + """ + Plain text or SSML to be synthesized to speech. + Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. + Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody + """ + + language: typing.Optional[str] = pydantic.Field(default=None) + """ + Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. + Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. + """ + + model: typing.Optional[GetStreamRequestModel] = pydantic.Field(default=None) + """ + Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. + """ + + options: typing.Optional[GetStreamOptionsRequest] = None + output_format: typing.Optional[AudioStreamOutputFormat] = pydantic.Field(default=None) + """ + The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. + """ + + voice_id: str = pydantic.Field() + """ + Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/speechify/audio/types/get_stream_request_model.py b/src/speechify/types/get_stream_request_model.py similarity index 100% rename from src/speechify/audio/types/get_stream_request_model.py rename to src/speechify/types/get_stream_request_model.py diff --git a/src/speechify/types/model.py b/src/speechify/types/model.py new file mode 100644 index 0000000..b8aad5f --- /dev/null +++ b/src/speechify/types/model.py @@ -0,0 +1,94 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class Model(UniversalBaseModel): + """ + One selectable text-to-speech model. + """ + + id: str = pydantic.Field() + """ + Model identifier. Pass this as the `model` parameter to + POST /v1/audio/speech or /v1/audio/stream. + """ + + name: str = pydantic.Field() + """ + Human-readable model name, for a model picker. + """ + + default: bool = pydantic.Field() + """ + Whether this is the model used when a synthesis request omits + `model`. Exactly one model in the list is the default. Distinct + from `recommended`: the default accepts every voice, while the + recommended model may serve a curated or English-only set. + """ + + recommended: bool = pydantic.Field() + """ + Whether this is the model we recommend for new integrations. + Exactly one model in the list is recommended, and it may differ + from the `default`. + """ + + deprecated: bool = pydantic.Field() + """ + Whether this is a legacy model. Advisory only: a deprecated model + stays selectable and behaves exactly as before, and nothing is + scheduled for removal. De-emphasise it in a picker and steer new + integrations to a current model. + """ + + description: str = pydantic.Field() + """ + One-line summary of the model, for a model picker. + """ + + languages: typing.List[str] = pydantic.Field() + """ + Languages the model can synthesize, as BCP-47 locale strings + matching the `language` request parameter (e.g. `en`, `fr-FR`). + English-only models return `["en"]`. This set reflects current + capability and can grow over time. + """ + + endpoints: typing.List[str] = pydantic.Field() + """ + The synthesis routes this model may be passed to. Only the + streaming-native models serve `/v1/audio/stream/with-timestamps`; + passing a model this list omits is a 400 rather than a degraded + response, so branch on it instead of discovering it at call time. + """ + + curated_voices: bool = pydantic.Field() + """ + Whether the model's stock voices are restricted to the set curated + for it. When true, pick a stock voice whose `models` array in + GET /v1/voices names this model; any other stock voice is rejected. + When false, every stock catalogue voice works. Cloned voices are + governed separately - always read each voice's own `models` array in + GET /v1/voices, which reflects what your workspace may actually + synthesize. + """ + + english_voices_only: bool = pydantic.Field() + """ + Whether the model rejects a non-English voice. Independent of + `languages`: a model can publish English only and still accept any + voice. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/speechify/types/models_response.py b/src/speechify/types/models_response.py new file mode 100644 index 0000000..def867b --- /dev/null +++ b/src/speechify/types/models_response.py @@ -0,0 +1,37 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .model import Model + + +class ModelsResponse(UniversalBaseModel): + """ + The catalog of text-to-speech models available for synthesis. + """ + + models: typing.List[Model] = pydantic.Field() + """ + The models selectable on the single-utterance synthesis endpoints. + """ + + dialogue_models: typing.List[Model] = pydantic.Field() + """ + The multi-speaker models selectable on POST /v1/audio/dialogue. + Disjoint from `models`: a dialogue model consumes a + speaker-attributed script rather than one utterance, so it is + rejected on the single-utterance endpoints and vice versa. Its + `default` marks the model that endpoint resolves to when a request + omits `model`, independently of the `models` default. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/speechify/voices/__init__.py b/src/speechify/voices/__init__.py index ac3d80d..4f05ee9 100644 --- a/src/speechify/voices/__init__.py +++ b/src/speechify/voices/__init__.py @@ -6,8 +6,14 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import CreateVoicesRequestGender -_dynamic_imports: typing.Dict[str, str] = {"CreateVoicesRequestGender": ".types"} + from .types import CreateVoicesRequestGender, ListVoicesRequestGender, ListVoicesRequestType + from . import consent_challenges +_dynamic_imports: typing.Dict[str, str] = { + "CreateVoicesRequestGender": ".types", + "ListVoicesRequestGender": ".types", + "ListVoicesRequestType": ".types", + "consent_challenges": ".consent_challenges", +} def __getattr__(attr_name: str) -> typing.Any: @@ -31,4 +37,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["CreateVoicesRequestGender"] +__all__ = ["CreateVoicesRequestGender", "ListVoicesRequestGender", "ListVoicesRequestType", "consent_challenges"] diff --git a/src/speechify/voices/client.py b/src/speechify/voices/client.py index 036c918..930cf22 100644 --- a/src/speechify/voices/client.py +++ b/src/speechify/voices/client.py @@ -1,5 +1,7 @@ # This file was auto-generated by Fern from our API Definition. +from __future__ import annotations + import typing from .. import core @@ -10,7 +12,11 @@ from ..types.list_voices_response import ListVoicesResponse from .raw_client import AsyncRawVoicesClient, RawVoicesClient from .types.create_voices_request_gender import CreateVoicesRequestGender +from .types.list_voices_request_gender import ListVoicesRequestGender +from .types.list_voices_request_type import ListVoicesRequestType +if typing.TYPE_CHECKING: + from .consent_challenges.client import AsyncConsentChallengesClient, ConsentChallengesClient # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -18,6 +24,8 @@ class VoicesClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._raw_client = RawVoicesClient(client_wrapper=client_wrapper) + self._client_wrapper = client_wrapper + self._consent_challenges: typing.Optional[ConsentChallengesClient] = None @property def with_raw_response(self) -> RawVoicesClient: @@ -35,15 +43,21 @@ def list( *, cursor: typing.Optional[str] = None, limit: typing.Optional[int] = None, + type: typing.Optional[ListVoicesRequestType] = None, + locale: typing.Optional[str] = None, + gender: typing.Optional[ListVoicesRequestGender] = None, + model: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[GetVoice, ListVoicesResponse]: """ Lists the voices available to the caller - the shared voice - catalog plus the workspace's personal cloned voices. By default + catalog plus the workspace's cloned voices, whichever member or + service-account key created them. By default the full catalogue is returned in one response. Pagination is opt-in: pass `limit` (and then `cursor` from the previous response) to page through the list while `has_more` is true. Max - page size is 200. + page size is 200. Narrow the list with the `type` and `locale` + filters (applied before pagination, so pages stay full). Parameters ---------- @@ -53,6 +67,22 @@ def list( limit : typing.Optional[int] Max items per page (default 50, max 200). + type : typing.Optional[ListVoicesRequestType] + Filter by voice type: `personal` (the workspace's cloned voices) + or `shared` (the public catalogue). Omit to return both. + + locale : typing.Optional[str] + Filter to voices whose locale matches this BCP-47 language range, + prefix-matched: `en` matches `en-US` and `en-GB`; `en-US` matches + only `en-US`. Case-insensitive. Omit to return all locales. + + gender : typing.Optional[ListVoicesRequestGender] + Filter by voice gender. Omit to return all genders. + + model : typing.Optional[str] + Filter to voices that support this model (as listed in each voice's + `models[]`), e.g. `simba-3.2`. Omit to return voices for all models. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -66,17 +96,28 @@ def list( from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) - response = client.voices.list() + response = client.voices.list( + locale="en", + model="simba-3.2", + ) for item in response: yield item # alternatively, you can paginate page-by-page for page in response.iter_pages(): yield page """ - return self._raw_client.list(cursor=cursor, limit=limit, request_options=request_options) + return self._raw_client.list( + cursor=cursor, + limit=limit, + type=type, + locale=locale, + gender=gender, + model=model, + request_options=request_options, + ) def create( self, @@ -84,14 +125,21 @@ def create( name: str, gender: CreateVoicesRequestGender, sample: core.File, - consent: str, + consent_challenge_id: str, + consent_recording: core.File, idempotency_key: typing.Optional[str] = None, locale: typing.Optional[str] = OMIT, avatar: typing.Optional[core.File] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> GetVoice: """ - Create a personal (cloned) voice for the user + Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + + Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + + The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + + Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog. Parameters ---------- @@ -107,10 +155,14 @@ def create( sample : core.File See core.File for more documentation - consent : str - A **string** representing the user consent information in JSON format - This should include the fullName and email of the consenting individual. - For example, `{"fullName": "John Doe", "email": "john@example.com"}` + consent_challenge_id : str + The `id` of the consent challenge this create consumes, from + `POST /v1/voices/consent-challenges`. Single use: once a + create has consumed it, whether or not that create + succeeded, it cannot be used again. + + consent_recording : core.File + See core.File for more documentation idempotency_key : typing.Optional[str] A client-generated key (an opaque string, max 255 chars) that makes a @@ -139,21 +191,22 @@ def create( from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) client.voices.create( idempotency_key="a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", name="name", gender="male", - consent="consent", + consent_challenge_id="consent_challenge_id", ) """ _response = self._raw_client.create( name=name, gender=gender, sample=sample, - consent=consent, + consent_challenge_id=consent_challenge_id, + consent_recording=consent_recording, idempotency_key=idempotency_key, locale=locale, avatar=avatar, @@ -164,9 +217,9 @@ def create( def get(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> GetVoice: """ Fetch a single voice by id - a shared catalogue voice or one of - the caller's own personal (cloned) voices. A personal voice that - belongs to another workspace returns 404, identical to an - unknown id, so voice inventory is never enumerable across tenants. + the workspace's cloned voices. A cloned voice that belongs to + another workspace returns 404, identical to an unknown id, so + voice inventory is never enumerable across tenants. Parameters ---------- @@ -186,7 +239,7 @@ def get(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) client.voices.get( @@ -198,7 +251,9 @@ def get(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] def delete(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: """ - Delete a personal (cloned) voice + Delete one of the workspace's cloned voices. Requires the + `content.manage` permission (owner, admin, or member); a + service-account key is authorized by its scopes instead. Parameters ---------- @@ -217,7 +272,7 @@ def delete(self, voice_id: str, *, request_options: typing.Optional[RequestOptio from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) client.voices.delete( @@ -251,7 +306,7 @@ def download_sample( from speechify import Speechify client = Speechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) client.voices.download_sample( @@ -261,10 +316,20 @@ def download_sample( with self._raw_client.download_sample(voice_id, request_options=request_options) as r: yield from r.data + @property + def consent_challenges(self): + if self._consent_challenges is None: + from .consent_challenges.client import ConsentChallengesClient # noqa: E402 + + self._consent_challenges = ConsentChallengesClient(client_wrapper=self._client_wrapper) + return self._consent_challenges + class AsyncVoicesClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._raw_client = AsyncRawVoicesClient(client_wrapper=client_wrapper) + self._client_wrapper = client_wrapper + self._consent_challenges: typing.Optional[AsyncConsentChallengesClient] = None @property def with_raw_response(self) -> AsyncRawVoicesClient: @@ -282,15 +347,21 @@ async def list( *, cursor: typing.Optional[str] = None, limit: typing.Optional[int] = None, + type: typing.Optional[ListVoicesRequestType] = None, + locale: typing.Optional[str] = None, + gender: typing.Optional[ListVoicesRequestGender] = None, + model: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[GetVoice, ListVoicesResponse]: """ Lists the voices available to the caller - the shared voice - catalog plus the workspace's personal cloned voices. By default + catalog plus the workspace's cloned voices, whichever member or + service-account key created them. By default the full catalogue is returned in one response. Pagination is opt-in: pass `limit` (and then `cursor` from the previous response) to page through the list while `has_more` is true. Max - page size is 200. + page size is 200. Narrow the list with the `type` and `locale` + filters (applied before pagination, so pages stay full). Parameters ---------- @@ -300,6 +371,22 @@ async def list( limit : typing.Optional[int] Max items per page (default 50, max 200). + type : typing.Optional[ListVoicesRequestType] + Filter by voice type: `personal` (the workspace's cloned voices) + or `shared` (the public catalogue). Omit to return both. + + locale : typing.Optional[str] + Filter to voices whose locale matches this BCP-47 language range, + prefix-matched: `en` matches `en-US` and `en-GB`; `en-US` matches + only `en-US`. Case-insensitive. Omit to return all locales. + + gender : typing.Optional[ListVoicesRequestGender] + Filter by voice gender. Omit to return all genders. + + model : typing.Optional[str] + Filter to voices that support this model (as listed in each voice's + `models[]`), e.g. `simba-3.2`. Omit to return voices for all models. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -315,13 +402,16 @@ async def list( from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) async def main() -> None: - response = await client.voices.list() + response = await client.voices.list( + locale="en", + model="simba-3.2", + ) async for item in response: yield item @@ -332,7 +422,15 @@ async def main() -> None: asyncio.run(main()) """ - return await self._raw_client.list(cursor=cursor, limit=limit, request_options=request_options) + return await self._raw_client.list( + cursor=cursor, + limit=limit, + type=type, + locale=locale, + gender=gender, + model=model, + request_options=request_options, + ) async def create( self, @@ -340,14 +438,21 @@ async def create( name: str, gender: CreateVoicesRequestGender, sample: core.File, - consent: str, + consent_challenge_id: str, + consent_recording: core.File, idempotency_key: typing.Optional[str] = None, locale: typing.Optional[str] = OMIT, avatar: typing.Optional[core.File] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> GetVoice: """ - Create a personal (cloned) voice for the user + Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + + Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + + The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + + Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog. Parameters ---------- @@ -363,10 +468,14 @@ async def create( sample : core.File See core.File for more documentation - consent : str - A **string** representing the user consent information in JSON format - This should include the fullName and email of the consenting individual. - For example, `{"fullName": "John Doe", "email": "john@example.com"}` + consent_challenge_id : str + The `id` of the consent challenge this create consumes, from + `POST /v1/voices/consent-challenges`. Single use: once a + create has consumed it, whether or not that create + succeeded, it cannot be used again. + + consent_recording : core.File + See core.File for more documentation idempotency_key : typing.Optional[str] A client-generated key (an opaque string, max 255 chars) that makes a @@ -397,7 +506,7 @@ async def create( from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) @@ -407,7 +516,7 @@ async def main() -> None: idempotency_key="a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", name="name", gender="male", - consent="consent", + consent_challenge_id="consent_challenge_id", ) @@ -417,7 +526,8 @@ async def main() -> None: name=name, gender=gender, sample=sample, - consent=consent, + consent_challenge_id=consent_challenge_id, + consent_recording=consent_recording, idempotency_key=idempotency_key, locale=locale, avatar=avatar, @@ -428,9 +538,9 @@ async def main() -> None: async def get(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> GetVoice: """ Fetch a single voice by id - a shared catalogue voice or one of - the caller's own personal (cloned) voices. A personal voice that - belongs to another workspace returns 404, identical to an - unknown id, so voice inventory is never enumerable across tenants. + the workspace's cloned voices. A cloned voice that belongs to + another workspace returns 404, identical to an unknown id, so + voice inventory is never enumerable across tenants. Parameters ---------- @@ -452,7 +562,7 @@ async def get(self, voice_id: str, *, request_options: typing.Optional[RequestOp from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) @@ -470,7 +580,9 @@ async def main() -> None: async def delete(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: """ - Delete a personal (cloned) voice + Delete one of the workspace's cloned voices. Requires the + `content.manage` permission (owner, admin, or member); a + service-account key is authorized by its scopes instead. Parameters ---------- @@ -491,7 +603,7 @@ async def delete(self, voice_id: str, *, request_options: typing.Optional[Reques from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) @@ -533,7 +645,7 @@ async def download_sample( from speechify import AsyncSpeechify client = AsyncSpeechify( - "2026-07-07", + "2026-09-13", token="YOUR_TOKEN", ) @@ -549,3 +661,11 @@ async def main() -> None: async with self._raw_client.download_sample(voice_id, request_options=request_options) as r: async for _chunk in r.data: yield _chunk + + @property + def consent_challenges(self): + if self._consent_challenges is None: + from .consent_challenges.client import AsyncConsentChallengesClient # noqa: E402 + + self._consent_challenges = AsyncConsentChallengesClient(client_wrapper=self._client_wrapper) + return self._consent_challenges diff --git a/src/speechify/voices/consent_challenges/__init__.py b/src/speechify/voices/consent_challenges/__init__.py new file mode 100644 index 0000000..5cde020 --- /dev/null +++ b/src/speechify/voices/consent_challenges/__init__.py @@ -0,0 +1,4 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + diff --git a/src/speechify/voices/consent_challenges/client.py b/src/speechify/voices/consent_challenges/client.py new file mode 100644 index 0000000..3b38eb5 --- /dev/null +++ b/src/speechify/voices/consent_challenges/client.py @@ -0,0 +1,181 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ...types.consent_challenge import ConsentChallenge +from .raw_client import AsyncRawConsentChallengesClient, RawConsentChallengesClient + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class ConsentChallengesClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawConsentChallengesClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawConsentChallengesClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawConsentChallengesClient + """ + return self._raw_client + + def create( + self, + *, + full_name: str, + idempotency_key: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ConsentChallenge: + """ + Start the consent check for a voice clone. + + Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + + A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + + Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. + + Parameters + ---------- + full_name : str + Full name of the person consenting to have their voice cloned. + Speechify binds it to the challenge and stores it with the consent + record, so the create that consumes the challenge does not carry it + and cannot change it. + + At most 120 bytes once UTF-8 encoded, which is 120 characters of + Latin script but around 40 of Chinese, Japanese or Korean. Stated in + bytes rather than as a `maxLength` because the two only agree on + single-byte scripts, and a character count that never over-accepts + would have to refuse Latin names at 30. A name over the limit comes + back as `validation_failed` reporting its measured length. + + idempotency_key : typing.Optional[str] + A client-generated key (an opaque string, max 255 chars) that makes a + side-effect POST safe to retry: the server runs the operation exactly + once and replays the first response (its status and body) for 24 hours. + Reusing a key with a different request body, or while the first request + is still in flight, returns `409 idempotency_conflict`. A replayed + response carries the `Idempotent-Replayed: true` header. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ConsentChallenge + The created consent challenge. + + Examples + -------- + from speechify import Speechify + + client = Speechify( + "2026-09-13", + token="YOUR_TOKEN", + ) + client.voices.consent_challenges.create( + idempotency_key="a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + full_name="Jane Doe", + ) + """ + _response = self._raw_client.create( + full_name=full_name, idempotency_key=idempotency_key, request_options=request_options + ) + return _response.data + + +class AsyncConsentChallengesClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawConsentChallengesClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawConsentChallengesClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawConsentChallengesClient + """ + return self._raw_client + + async def create( + self, + *, + full_name: str, + idempotency_key: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ConsentChallenge: + """ + Start the consent check for a voice clone. + + Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + + A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + + Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. + + Parameters + ---------- + full_name : str + Full name of the person consenting to have their voice cloned. + Speechify binds it to the challenge and stores it with the consent + record, so the create that consumes the challenge does not carry it + and cannot change it. + + At most 120 bytes once UTF-8 encoded, which is 120 characters of + Latin script but around 40 of Chinese, Japanese or Korean. Stated in + bytes rather than as a `maxLength` because the two only agree on + single-byte scripts, and a character count that never over-accepts + would have to refuse Latin names at 30. A name over the limit comes + back as `validation_failed` reporting its measured length. + + idempotency_key : typing.Optional[str] + A client-generated key (an opaque string, max 255 chars) that makes a + side-effect POST safe to retry: the server runs the operation exactly + once and replays the first response (its status and body) for 24 hours. + Reusing a key with a different request body, or while the first request + is still in flight, returns `409 idempotency_conflict`. A replayed + response carries the `Idempotent-Replayed: true` header. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ConsentChallenge + The created consent challenge. + + Examples + -------- + import asyncio + + from speechify import AsyncSpeechify + + client = AsyncSpeechify( + "2026-09-13", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.voices.consent_challenges.create( + idempotency_key="a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + full_name="Jane Doe", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create( + full_name=full_name, idempotency_key=idempotency_key, request_options=request_options + ) + return _response.data diff --git a/src/speechify/voices/consent_challenges/raw_client.py b/src/speechify/voices/consent_challenges/raw_client.py new file mode 100644 index 0000000..ea49e68 --- /dev/null +++ b/src/speechify/voices/consent_challenges/raw_client.py @@ -0,0 +1,392 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.parse_error import ParsingError +from ...core.pydantic_utilities import parse_obj_as +from ...core.request_options import RequestOptions +from ...errors.bad_gateway_error import BadGatewayError +from ...errors.bad_request_error import BadRequestError +from ...errors.conflict_error import ConflictError +from ...errors.forbidden_error import ForbiddenError +from ...errors.internal_server_error import InternalServerError +from ...errors.payment_required_error import PaymentRequiredError +from ...errors.service_unavailable_error import ServiceUnavailableError +from ...errors.too_many_requests_error import TooManyRequestsError +from ...errors.unauthorized_error import UnauthorizedError +from ...types.consent_challenge import ConsentChallenge +from ...types.error import Error +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawConsentChallengesClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def create( + self, + *, + full_name: str, + idempotency_key: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ConsentChallenge]: + """ + Start the consent check for a voice clone. + + Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + + A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + + Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. + + Parameters + ---------- + full_name : str + Full name of the person consenting to have their voice cloned. + Speechify binds it to the challenge and stores it with the consent + record, so the create that consumes the challenge does not carry it + and cannot change it. + + At most 120 bytes once UTF-8 encoded, which is 120 characters of + Latin script but around 40 of Chinese, Japanese or Korean. Stated in + bytes rather than as a `maxLength` because the two only agree on + single-byte scripts, and a character count that never over-accepts + would have to refuse Latin names at 30. A name over the limit comes + back as `validation_failed` reporting its measured length. + + idempotency_key : typing.Optional[str] + A client-generated key (an opaque string, max 255 chars) that makes a + side-effect POST safe to retry: the server runs the operation exactly + once and replays the first response (its status and body) for 24 hours. + Reusing a key with a different request body, or while the first request + is still in flight, returns `409 idempotency_conflict`. A replayed + response carries the `Idempotent-Replayed: true` header. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ConsentChallenge] + The created consent challenge. + """ + _response = self._client_wrapper.httpx_client.request( + "v1/voices/consent-challenges", + method="POST", + json={ + "full_name": full_name, + }, + headers={ + "content-type": "application/json", + "Idempotency-Key": str(idempotency_key) if idempotency_key is not None else None, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ConsentChallenge, + parse_obj_as( + type_=ConsentChallenge, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 402: + raise PaymentRequiredError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 502: + raise BadGatewayError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 503: + raise ServiceUnavailableError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawConsentChallengesClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def create( + self, + *, + full_name: str, + idempotency_key: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ConsentChallenge]: + """ + Start the consent check for a voice clone. + + Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + + A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + + Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. + + Parameters + ---------- + full_name : str + Full name of the person consenting to have their voice cloned. + Speechify binds it to the challenge and stores it with the consent + record, so the create that consumes the challenge does not carry it + and cannot change it. + + At most 120 bytes once UTF-8 encoded, which is 120 characters of + Latin script but around 40 of Chinese, Japanese or Korean. Stated in + bytes rather than as a `maxLength` because the two only agree on + single-byte scripts, and a character count that never over-accepts + would have to refuse Latin names at 30. A name over the limit comes + back as `validation_failed` reporting its measured length. + + idempotency_key : typing.Optional[str] + A client-generated key (an opaque string, max 255 chars) that makes a + side-effect POST safe to retry: the server runs the operation exactly + once and replays the first response (its status and body) for 24 hours. + Reusing a key with a different request body, or while the first request + is still in flight, returns `409 idempotency_conflict`. A replayed + response carries the `Idempotent-Replayed: true` header. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ConsentChallenge] + The created consent challenge. + """ + _response = await self._client_wrapper.httpx_client.request( + "v1/voices/consent-challenges", + method="POST", + json={ + "full_name": full_name, + }, + headers={ + "content-type": "application/json", + "Idempotency-Key": str(idempotency_key) if idempotency_key is not None else None, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ConsentChallenge, + parse_obj_as( + type_=ConsentChallenge, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 402: + raise PaymentRequiredError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 502: + raise BadGatewayError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 503: + raise ServiceUnavailableError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/speechify/voices/raw_client.py b/src/speechify/voices/raw_client.py index 8645cf9..ca8551a 100644 --- a/src/speechify/voices/raw_client.py +++ b/src/speechify/voices/raw_client.py @@ -16,6 +16,7 @@ from ..errors.bad_gateway_error import BadGatewayError from ..errors.bad_request_error import BadRequestError from ..errors.conflict_error import ConflictError +from ..errors.content_too_large_error import ContentTooLargeError from ..errors.forbidden_error import ForbiddenError from ..errors.internal_server_error import InternalServerError from ..errors.not_found_error import NotFoundError @@ -28,6 +29,8 @@ from ..types.get_voice import GetVoice from ..types.list_voices_response import ListVoicesResponse from .types.create_voices_request_gender import CreateVoicesRequestGender +from .types.list_voices_request_gender import ListVoicesRequestGender +from .types.list_voices_request_type import ListVoicesRequestType from pydantic import ValidationError # this is used as the default value for optional parameters @@ -43,15 +46,21 @@ def list( *, cursor: typing.Optional[str] = None, limit: typing.Optional[int] = None, + type: typing.Optional[ListVoicesRequestType] = None, + locale: typing.Optional[str] = None, + gender: typing.Optional[ListVoicesRequestGender] = None, + model: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> SyncPager[GetVoice, ListVoicesResponse]: """ Lists the voices available to the caller - the shared voice - catalog plus the workspace's personal cloned voices. By default + catalog plus the workspace's cloned voices, whichever member or + service-account key created them. By default the full catalogue is returned in one response. Pagination is opt-in: pass `limit` (and then `cursor` from the previous response) to page through the list while `has_more` is true. Max - page size is 200. + page size is 200. Narrow the list with the `type` and `locale` + filters (applied before pagination, so pages stay full). Parameters ---------- @@ -61,6 +70,22 @@ def list( limit : typing.Optional[int] Max items per page (default 50, max 200). + type : typing.Optional[ListVoicesRequestType] + Filter by voice type: `personal` (the workspace's cloned voices) + or `shared` (the public catalogue). Omit to return both. + + locale : typing.Optional[str] + Filter to voices whose locale matches this BCP-47 language range, + prefix-matched: `en` matches `en-US` and `en-GB`; `en-US` matches + only `en-US`. Case-insensitive. Omit to return all locales. + + gender : typing.Optional[ListVoicesRequestGender] + Filter by voice gender. Omit to return all genders. + + model : typing.Optional[str] + Filter to voices that support this model (as listed in each voice's + `models[]`), e.g. `simba-3.2`. Omit to return voices for all models. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -75,6 +100,10 @@ def list( params={ "cursor": cursor, "limit": limit, + "type": type, + "locale": locale, + "gender": gender, + "model": model, }, request_options=request_options, ) @@ -93,6 +122,10 @@ def list( _get_next = lambda: self.list( cursor=_parsed_next, limit=limit, + type=type, + locale=locale, + gender=gender, + model=model, request_options=request_options, ) return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) @@ -166,14 +199,21 @@ def create( name: str, gender: CreateVoicesRequestGender, sample: core.File, - consent: str, + consent_challenge_id: str, + consent_recording: core.File, idempotency_key: typing.Optional[str] = None, locale: typing.Optional[str] = OMIT, avatar: typing.Optional[core.File] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[GetVoice]: """ - Create a personal (cloned) voice for the user + Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + + Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + + The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + + Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog. Parameters ---------- @@ -189,10 +229,14 @@ def create( sample : core.File See core.File for more documentation - consent : str - A **string** representing the user consent information in JSON format - This should include the fullName and email of the consenting individual. - For example, `{"fullName": "John Doe", "email": "john@example.com"}` + consent_challenge_id : str + The `id` of the consent challenge this create consumes, from + `POST /v1/voices/consent-challenges`. Single use: once a + create has consumed it, whether or not that create + succeeded, it cannot be used again. + + consent_recording : core.File + See core.File for more documentation idempotency_key : typing.Optional[str] A client-generated key (an opaque string, max 255 chars) that makes a @@ -223,11 +267,12 @@ def create( "name": name, "locale": locale, "gender": gender, - "consent": consent, + "consent_challenge_id": consent_challenge_id, }, files={ "sample": sample, **({"avatar": avatar} if avatar is not None else {}), + "consent_recording": consent_recording, }, headers={ "Idempotency-Key": str(idempotency_key) if idempotency_key is not None else None, @@ -301,6 +346,17 @@ def create( ), ), ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -368,9 +424,9 @@ def create( def get(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[GetVoice]: """ Fetch a single voice by id - a shared catalogue voice or one of - the caller's own personal (cloned) voices. A personal voice that - belongs to another workspace returns 404, identical to an - unknown id, so voice inventory is never enumerable across tenants. + the workspace's cloned voices. A cloned voice that belongs to + another workspace returns 404, identical to an unknown id, so + voice inventory is never enumerable across tenants. Parameters ---------- @@ -488,7 +544,9 @@ def get(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] def delete(self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: """ - Delete a personal (cloned) voice + Delete one of the workspace's cloned voices. Requires the + `content.manage` permission (owner, admin, or member); a + service-account key is authorized by its scopes instead. Parameters ---------- @@ -755,15 +813,21 @@ async def list( *, cursor: typing.Optional[str] = None, limit: typing.Optional[int] = None, + type: typing.Optional[ListVoicesRequestType] = None, + locale: typing.Optional[str] = None, + gender: typing.Optional[ListVoicesRequestGender] = None, + model: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncPager[GetVoice, ListVoicesResponse]: """ Lists the voices available to the caller - the shared voice - catalog plus the workspace's personal cloned voices. By default + catalog plus the workspace's cloned voices, whichever member or + service-account key created them. By default the full catalogue is returned in one response. Pagination is opt-in: pass `limit` (and then `cursor` from the previous response) to page through the list while `has_more` is true. Max - page size is 200. + page size is 200. Narrow the list with the `type` and `locale` + filters (applied before pagination, so pages stay full). Parameters ---------- @@ -773,6 +837,22 @@ async def list( limit : typing.Optional[int] Max items per page (default 50, max 200). + type : typing.Optional[ListVoicesRequestType] + Filter by voice type: `personal` (the workspace's cloned voices) + or `shared` (the public catalogue). Omit to return both. + + locale : typing.Optional[str] + Filter to voices whose locale matches this BCP-47 language range, + prefix-matched: `en` matches `en-US` and `en-GB`; `en-US` matches + only `en-US`. Case-insensitive. Omit to return all locales. + + gender : typing.Optional[ListVoicesRequestGender] + Filter by voice gender. Omit to return all genders. + + model : typing.Optional[str] + Filter to voices that support this model (as listed in each voice's + `models[]`), e.g. `simba-3.2`. Omit to return voices for all models. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -787,6 +867,10 @@ async def list( params={ "cursor": cursor, "limit": limit, + "type": type, + "locale": locale, + "gender": gender, + "model": model, }, request_options=request_options, ) @@ -807,6 +891,10 @@ async def _get_next(): return await self.list( cursor=_parsed_next, limit=limit, + type=type, + locale=locale, + gender=gender, + model=model, request_options=request_options, ) @@ -881,14 +969,21 @@ async def create( name: str, gender: CreateVoicesRequestGender, sample: core.File, - consent: str, + consent_challenge_id: str, + consent_recording: core.File, idempotency_key: typing.Optional[str] = None, locale: typing.Optional[str] = OMIT, avatar: typing.Optional[core.File] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[GetVoice]: """ - Create a personal (cloned) voice for the user + Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + + Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + + The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + + Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog. Parameters ---------- @@ -904,10 +999,14 @@ async def create( sample : core.File See core.File for more documentation - consent : str - A **string** representing the user consent information in JSON format - This should include the fullName and email of the consenting individual. - For example, `{"fullName": "John Doe", "email": "john@example.com"}` + consent_challenge_id : str + The `id` of the consent challenge this create consumes, from + `POST /v1/voices/consent-challenges`. Single use: once a + create has consumed it, whether or not that create + succeeded, it cannot be used again. + + consent_recording : core.File + See core.File for more documentation idempotency_key : typing.Optional[str] A client-generated key (an opaque string, max 255 chars) that makes a @@ -938,11 +1037,12 @@ async def create( "name": name, "locale": locale, "gender": gender, - "consent": consent, + "consent_challenge_id": consent_challenge_id, }, files={ "sample": sample, **({"avatar": avatar} if avatar is not None else {}), + "consent_recording": consent_recording, }, headers={ "Idempotency-Key": str(idempotency_key) if idempotency_key is not None else None, @@ -1016,6 +1116,17 @@ async def create( ), ), ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -1085,9 +1196,9 @@ async def get( ) -> AsyncHttpResponse[GetVoice]: """ Fetch a single voice by id - a shared catalogue voice or one of - the caller's own personal (cloned) voices. A personal voice that - belongs to another workspace returns 404, identical to an - unknown id, so voice inventory is never enumerable across tenants. + the workspace's cloned voices. A cloned voice that belongs to + another workspace returns 404, identical to an unknown id, so + voice inventory is never enumerable across tenants. Parameters ---------- @@ -1207,7 +1318,9 @@ async def delete( self, voice_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[None]: """ - Delete a personal (cloned) voice + Delete one of the workspace's cloned voices. Requires the + `content.manage` permission (owner, admin, or member); a + service-account key is authorized by its scopes instead. Parameters ---------- diff --git a/src/speechify/voices/types/__init__.py b/src/speechify/voices/types/__init__.py index caa91ac..d013e55 100644 --- a/src/speechify/voices/types/__init__.py +++ b/src/speechify/voices/types/__init__.py @@ -7,7 +7,13 @@ if typing.TYPE_CHECKING: from .create_voices_request_gender import CreateVoicesRequestGender -_dynamic_imports: typing.Dict[str, str] = {"CreateVoicesRequestGender": ".create_voices_request_gender"} + from .list_voices_request_gender import ListVoicesRequestGender + from .list_voices_request_type import ListVoicesRequestType +_dynamic_imports: typing.Dict[str, str] = { + "CreateVoicesRequestGender": ".create_voices_request_gender", + "ListVoicesRequestGender": ".list_voices_request_gender", + "ListVoicesRequestType": ".list_voices_request_type", +} def __getattr__(attr_name: str) -> typing.Any: @@ -31,4 +37,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["CreateVoicesRequestGender"] +__all__ = ["CreateVoicesRequestGender", "ListVoicesRequestGender", "ListVoicesRequestType"] diff --git a/src/speechify/voices/types/list_voices_request_gender.py b/src/speechify/voices/types/list_voices_request_gender.py new file mode 100644 index 0000000..7cea93a --- /dev/null +++ b/src/speechify/voices/types/list_voices_request_gender.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +ListVoicesRequestGender = typing.Union[typing.Literal["male", "female", "not_specified"], typing.Any] diff --git a/src/speechify/voices/types/list_voices_request_type.py b/src/speechify/voices/types/list_voices_request_type.py new file mode 100644 index 0000000..a4aa04a --- /dev/null +++ b/src/speechify/voices/types/list_voices_request_type.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +ListVoicesRequestType = typing.Union[typing.Literal["personal", "shared"], typing.Any] From 48676adbc0e8a177e4a9981336e2cf2b9cbb614b Mon Sep 17 00:00:00 2001 From: Luke Oliff <289678208+luke-speechify@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:19:29 +0100 Subject: [PATCH 2/2] fix: wire release-please to bump every version-bearing property Removes the dangling $.project.version jsonpath ([project] is dynamic), covers .fern/metadata.json, stamps the Fern-generated version strings from the release tag at publish time, restores AGENTS.md and manual-publish.yml behind .fernignore, and gates publish on a version/tag assertion. client_wrapper.py is Fern-generated and cannot be .fernignore'd, so an x-release-please-version marker does not survive there: every regeneration strips it and the generic updater goes back to silently no-opping, leaving User-Agent and X-Fern-SDK-Version reporting a version the SDK is not. That is the same class of failure that shipped 2.0.1 to PyPI under a 3.0.0 tag. The generic extra-files entry and both markers are gone. The publish job now rewrites those two literals from the release tag before the assertion and before poetry publish --build. The stamp fails the build when a target literal is not found, so a regeneration that renames or restructures those lines breaks the release loudly instead of shipping a stale version silently. Order is stamp, assert, publish. The stamp applies to the CI checkout only and is never committed back, so those two strings read stale on main between releases by design; the published artifact is always built from the stamped and asserted tree. AGENTS.md documents this so it does not get "fixed" later. --- .fernignore | 7 +- .github/workflows/manual-publish.yml | 51 ++++++ .github/workflows/release-please.yml | 189 +++++++++++++++++++++ AGENTS.md | 240 +++++++++++++++++++++++++++ release-please-config.json | 10 +- 5 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/manual-publish.yml create mode 100644 AGENTS.md diff --git a/.fernignore b/.fernignore index a25a56b..4910365 100644 --- a/.fernignore +++ b/.fernignore @@ -8,4 +8,9 @@ .github/workflows/release-please.yml release-please-config.json .release-please-manifest.json -CHANGELOG.md \ No newline at end of file +CHANGELOG.md + +# Release safety net. Regen deleted both of these once; keep them listed. +# manual-publish.yml is the recovery path for a botched release. +.github/workflows/manual-publish.yml +AGENTS.md \ No newline at end of file diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml new file mode 100644 index 0000000..57a7e11 --- /dev/null +++ b/.github/workflows/manual-publish.yml @@ -0,0 +1,51 @@ +name: manual-publish + +# Manual, one-off publisher for an already-tagged release whose automatic +# publish did not run (or ran with wrong metadata). Checks out an explicit +# tag/ref, asserts the built version matches, then publishes to PyPI. +on: + workflow_dispatch: + inputs: + ref: + description: "Git tag/ref to publish (e.g. 3.0.0)" + required: true + default: "3.0.0" + expected_version: + description: "Version the build MUST produce (guard against stale metadata)" + required: true + default: "3.0.0" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout ref + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Bootstrap poetry + run: curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Build + run: | + rm -rf dist + poetry build + # inputs.* reach the script through env, never through ${{ }} interpolation: + # a dispatch input is attacker-controllable text and would otherwise be + # pasted into the shell before bash ever parses it. + - name: Assert built version matches expected + env: + EXPECTED_VERSION: ${{ inputs.expected_version }} + run: | + set -euo pipefail + test -f "dist/speechify_api-${EXPECTED_VERSION}.tar.gz" \ + || { echo "::error::expected dist/speechify_api-${EXPECTED_VERSION}.tar.gz not found"; ls -la dist; exit 1; } + test -f "dist/speechify_api-${EXPECTED_VERSION}-py3-none-any.whl" \ + || { echo "::error::expected wheel for ${EXPECTED_VERSION} not found"; ls -la dist; exit 1; } + - name: Publish to PyPI + run: | + poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} + poetry publish diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5f64cc0..f57d690 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -79,6 +79,195 @@ jobs: curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 - name: Install dependencies run: poetry install + # client_wrapper.py is Fern-generated and cannot be .fernignore'd, so an + # in-repo release-please marker does not survive: every regeneration strips + # the "# x-release-please-version" comments, the generic updater silently + # stops bumping these headers, and the SDK reports a version it is not. + # The tag is the one input a regeneration cannot touch, so stamp from it. + # + # These rewrites apply to the CI checkout ONLY and are never committed + # back. main therefore carries whatever version Fern last generated, and + # those two strings are expected to read stale between releases — that is + # by design, not drift. The published artifact is always correct because + # it is built from this stamped tree. Do not "fix" them on main. + - name: Stamp Fern-generated version strings from the release tag + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + python3 - <<'PY' + import os + import re + import sys + + + def fail(message): + sys.exit(f"::error::{message}") + + + raw_tag = os.environ["TAG_NAME"].strip() + if not raw_tag: + fail("release-please produced an empty tag_name; refusing to stamp") + + # include-v-in-tag is false, so the tag is bare semver. Tolerate a + # leading "v" so flipping that setting does not fail a valid release. + tag = raw_tag[1:] if raw_tag.startswith("v") else raw_tag + + # A prerelease or build suffix is legal and must survive verbatim: + # 4.0.0-rc.1 stamps as 4.0.0-rc.1, never as 4.0.0. Anything that is not + # semver means the tag is not what we think it is — stop before writing. + if not re.fullmatch( + r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", tag + ): + fail(f"tag {raw_tag!r} is not bare semver; refusing to stamp") + + CLIENT_WRAPPER_PATH = "src/speechify/core/client_wrapper.py" + + # The "version" group is rewritten; "prefix" and "suffix" are put back + # untouched, so the surrounding literal has to match exactly or not at + # all. Both header values are stamped from the same tag. + TARGETS = ( + ( + "User-Agent", + r'(?P"User-Agent":\s*"speechify-api/)(?P[^"]*)(?P")', + ), + ( + "X-Fern-SDK-Version", + r'(?P"X-Fern-SDK-Version":\s*")(?P[^"]*)(?P")', + ), + ) + + try: + with open(CLIENT_WRAPPER_PATH, encoding="utf-8") as handle: + original = handle.read() + except OSError as error: + fail(f"cannot read {CLIENT_WRAPPER_PATH}: {error}") + + + def stamp_tag_into(match): + return f"{match.group('prefix')}{tag}{match.group('suffix')}" + + + stamped = original + for description, pattern in TARGETS: + replaced = [m.group("version") for m in re.finditer(pattern, stamped)] + if not replaced: + # Never skip. A regeneration that renames or restructures this + # line has to break the release here, loudly — a stamp that + # quietly finds nothing is exactly how 2.0.1 shipped as 3.0.0. + fail( + f"{CLIENT_WRAPPER_PATH}: no {description} version literal " + "matched. Fern regeneration has changed this file; update " + "the stamp step and the publish assertion before releasing." + ) + stamped = re.sub(pattern, stamp_tag_into, stamped) + for previous in replaced: + status = "unchanged" if previous == tag else "stamped" + print(f" [{status}] {description}: {previous} -> {tag}") + + if stamped == original: + print(f"{CLIENT_WRAPPER_PATH} already at {tag}; nothing to rewrite.") + else: + with open(CLIENT_WRAPPER_PATH, "w", encoding="utf-8") as handle: + handle.write(stamped) + print(f"{CLIENT_WRAPPER_PATH} stamped to {tag}.") + PY + # A stale version string is how speechify-api 2.0.1 shipped under a 3.0.0 + # tag. Every version-bearing value must agree with the tag before upload — + # PyPI is immutable, so this is the last point where it is still cheap. + # Runs after the stamp step: it is an independent check of the result, not + # a substitute for it, and it still covers the files nothing stamps. + - name: Assert every version string matches the release tag + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import re + import sys + + + def fail(message): + sys.exit(f"::error::{message}") + + + raw_tag = os.environ["TAG_NAME"].strip() + if not raw_tag: + fail("release-please produced an empty tag_name; refusing to publish") + + # include-v-in-tag is false, so the tag is bare semver. Tolerate a + # leading "v" so flipping that setting does not fail a valid release. + tag = raw_tag[1:] if raw_tag.startswith("v") else raw_tag + + + def read(path): + try: + with open(path, encoding="utf-8") as handle: + return handle.read() + except OSError as error: + fail(f"cannot read {path}: {error}") + + + def search(text, pattern, description): + match = re.search(pattern, text, re.MULTILINE | re.DOTALL) + if not match: + fail(f"no version found for {description}") + return match.group("version") + + + pyproject = read("pyproject.toml") + # [project] declares dynamic = ["version"], so poetry-core builds the + # artifact from [tool.poetry].version. That table is the real source. + poetry_table = re.search( + r"^\[tool\.poetry\]\s*$(?P.*?)(?=^\[|\Z)", + pyproject, + re.MULTILINE | re.DOTALL, + ) + if not poetry_table: + fail("pyproject.toml has no [tool.poetry] table") + + client_wrapper = read("src/speechify/core/client_wrapper.py") + metadata = json.loads(read(".fern/metadata.json")) + if "sdkVersion" not in metadata: + fail(".fern/metadata.json has no sdkVersion key") + + found = { + "pyproject.toml [tool.poetry].version": search( + poetry_table.group("body"), + r'^version\s*=\s*"(?P[^"]+)"', + "[tool.poetry].version", + ), + "client_wrapper.py User-Agent": search( + client_wrapper, + r'"User-Agent":\s*"speechify-api/(?P[^"]+)"', + "client_wrapper.py User-Agent", + ), + "client_wrapper.py X-Fern-SDK-Version": search( + client_wrapper, + r'"X-Fern-SDK-Version":\s*"(?P[^"]+)"', + "client_wrapper.py X-Fern-SDK-Version", + ), + ".fern/metadata.json sdkVersion": metadata["sdkVersion"], + } + + print(f"release tag: {raw_tag} (normalised: {tag})") + for source, version in found.items(): + status = "ok" if version == tag else "MISMATCH" + print(f" [{status}] {source}: {version}") + + mismatched = [s for s, v in found.items() if v != tag] + if mismatched: + print( + "::error::version strings disagree with tag " + f"{tag}: {', '.join(mismatched)}. " + "Refusing to publish — PyPI uploads are irreversible." + ) + sys.exit(1) + + print(f"All version strings agree with {tag}.") + PY - name: Publish to PyPI run: | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b594e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,240 @@ +# Agent instructions — speechify-api (Python SDK) + +This is a **Fern-generated** SDK. It is published to **PyPI** as `speechify-api`. +The source of truth for the API surface lives in the `SpeechifyInc/speechify-api` +repo (`fern/`); this repo receives generated code on the `sdk-release` branch. + +**Read this before doing anything that touches a release.** A botched release here +publishes to an immutable public registry and breaks the contract with every +customer who installs the package. This file exists because we already did that +once — see "Postmortem" below. + +## The golden rule + +**Publishing to PyPI is IRREVERSIBLE.** A version, once uploaded, can never be +overwritten or reused — only *yanked* (hidden from resolution), and yanking +requires a human web session (there is no token/API/CI path for it). + +Never trigger a publish until you have, in order: + +1. **Audited the release plumbing** (this file, the workflow, the config). +2. **Confirmed the version is correct in EVERY version-bearing file** (see checklist). +3. **Dry-run built and asserted the artifact version** matches the intended tag. +4. **Got explicit human sign-off** for the publish itself. + +Do NOT admin-merge release PRs to force a publish. Do NOT bypass required reviews. + +## Version-surface checklist — the mistake we keep making + +The #1 failure mode is **a generated version string not getting bumped**, so the +package publishes under the wrong number (or reports a false version to the API). +A single stale string is a published contract break, not a typo. + +There are exactly **four** version literals in the tree, plus the tag. All five +must agree **in the published artifact** — check every one, never check one and +assume the rest. Two are committed by the release PR, two are stamped in CI: + +| Literal | Who sets it | Correct on `main`? | +|---|---|---| +| `pyproject.toml` → `[tool.poetry].version` | release-please `extra-files` (toml) | yes — committed | +| `.fern/metadata.json` → `sdkVersion` | release-please `extra-files` (json) | yes — committed | +| `client_wrapper.py` → `User-Agent` | **stamped in CI from the tag** | no — stale between releases, by design | +| `client_wrapper.py` → `X-Fern-SDK-Version` | **stamped in CI from the tag** | no — stale between releases, by design | + +- `pyproject.toml` → `[tool.poetry].version` — the artifact version. `[project]` + declares `dynamic = ["version"]` and has **no** `version` key; poetry-core reads + `[tool.poetry]`. Do not add `[project].version`. **Load-bearing**: `poetry build` + reads it, so it must be committed by the release PR. +- `src/speechify/core/client_wrapper.py` → `User-Agent` **and** `X-Fern-SDK-Version` +- `.fern/metadata.json` → `sdkVersion` +- the git tag created for the release (bare semver — `include-v-in-tag: false`) + +**The tag is the source of truth.** Everything else is either bumped to match it +by release-please or rewritten to match it at publish time. + +Not version literals, so leave them alone: + +- `.release-please-manifest.json` — release-please's own state, it owns this. +- `src/speechify/version.py` — resolves `__version__` from installed package + metadata at import time. Nothing to bump. + +Fast audit: + +```bash +grep -rnE '[0-9]+\.[0-9]+\.[0-9]+' \ + pyproject.toml src/speechify/core/client_wrapper.py .fern/metadata.json +``` + +On a checked-out `main` this grep will show `client_wrapper.py` behind the last +release. **That is expected** — see the stamping section below. What must never +disagree is the *published* artifact, which the publish job builds from a stamped +and asserted tree. + +`X-Fern-SDK-Version` / `User-Agent` are sent on every request. If they lie, your +telemetry, version-gating, and support debugging are all wrong for that release. + +## Known plumbing traps + +- **The default `python` release-type does NOT bump this `pyproject.toml`.** Its + updater resolves `parsed.project || parsed.tool.poetry` — our `[project]` table + exists, so it wins the `||`, has no `version`, declares `dynamic = ["version"]`, + and the updater logs `dynamic version found ... Skipping update` and returns the + file unchanged. It never falls through to `[tool.poetry]`. The `extra-files` + entry `$.tool.poetry.version` is therefore **load-bearing, not redundant** — + delete it and the published artifact silently keeps the old version. +- **Do not add a `$.project.version` jsonpath.** There is no such key. The + `GenericToml` updater logs `No entries modified` and no-ops, so it buys nothing + while implying coverage that does not exist. Adding a static `[project].version` + to "fix" it would contradict `dynamic = ["version"]` and break the PEP 621 build. +- **Never point a `type: "generic"` extra-file at a Fern-generated file.** The + `generic` updater only rewrites a line carrying an `x-release-please-version` + marker, and it no-ops **silently** when the marker is absent. `client_wrapper.py` + is generated and **cannot** be `.fernignore`d, so every regeneration strips the + marker and re-arms that trap. This is the exact failure that shipped `2.0.1` + under the `3.0.0` tag. There is deliberately no `generic` entry in + `release-please-config.json` — do not add one back. +- **`.fern/metadata.json` was uncovered until now.** It drifted to `2.0.1` while + the package was on `3.0.1`. It is covered by a `json` updater on `$.sdkVersion`. + It is *not* a marker file — the `json` updater is jsonpath-based and survives + regeneration, which is why it is safe to rely on. + +## Version stamping — how `client_wrapper.py` gets the right version + +**A fix that lives inside a generated file is not a fix.** Markers are gone. +Instead, the `publish` job rewrites the two Fern-owned literals from +`needs.release-please.outputs.tag_name` immediately before building. + +The `publish` job runs three steps in this order, and the order is the design: + +1. **Stamp** — rewrite `User-Agent` and `X-Fern-SDK-Version` in + `client_wrapper.py` from the tag. +2. **Assert** — independently re-read all four literals and compare each to the + tag. Fails on a mismatch *or* on a literal that has gone missing. +3. **Publish** — `poetry publish --build`. + +Never reorder these, and never let the publish step run without both in front of +it. The assertion is a check on the stamp's result, not a substitute for it. + +The stamp step **fails the build if a target literal is not found**. A Fern +regeneration that renames or restructures those lines must break the release +loudly — a stamp that quietly matches nothing is the same silent-no-op class that +caused the 3.0.0 incident. If it fails, fix the pattern in the stamp step *and* +the matching pattern in the assertion; do not delete the check. + +`include-v-in-tag` is false so the tag is bare semver. The stamp tolerates a +leading `v`, rejects anything that is not semver, and preserves a prerelease or +build suffix verbatim (`4.0.0-rc.1` stamps as `4.0.0-rc.1`, never `4.0.0`). + +### Stale strings on `main` are expected — do not "fix" them + +The stamp edits the **CI checkout only**. Nothing is committed back. So between +releases, `main`'s `client_wrapper.py` carries whatever version Fern last +generated, and it will usually look out of date. + +**This is correct behaviour, not a bug.** The published artifact is always right +because it is built from the stamped tree. If you "helpfully" hand-edit those +strings on `main`, you gain nothing — the next regeneration overwrites you and +the next release stamps over you anyway. Leave them alone. + +## After every Fern regeneration + +`.fernignore` protects these from being overwritten — they are safe: + +`.github/workflows/ci.yml`, `.github/workflows/release-please.yml`, +`.github/workflows/manual-publish.yml`, `release-please-config.json`, +`.release-please-manifest.json`, `CHANGELOG.md`, `AGENTS.md`. + +These are **regenerated** and must be re-checked by hand every single time: + +- [ ] `src/speechify/core/client_wrapper.py` — the two header lines still have + the shape the stamp step matches. Do **not** check the version values + themselves; they are stamped at publish time and are expected to be stale: + + ```python + "User-Agent": "speechify-api/", + "X-Fern-SDK-Version": "", + ``` + + Same key names, same quoting, same `speechify-api/` prefix. If a regen + changed any of that, update the patterns in **both** the stamp step and the + publish assertion in `release-please.yml`. +- [ ] `pyproject.toml` — `[tool.poetry].version` still present; `[project]` still + `dynamic` and still has no `version` key. +- [ ] `.fern/metadata.json` — `sdkVersion` key still named that. +- [ ] `release-please-config.json` — still has **no** `type: "generic"` entry. + +Verify the shapes without caring about the values: + +```bash +grep -nE '"(User-Agent|X-Fern-SDK-Version)":' src/speechify/core/client_wrapper.py +``` + +If any of the above moved or was renamed, fix it *before* merging — the stamp +step and the publish assertion will both fail the release otherwise (by design). + +## Publish assertion + +`release-please.yml`'s `publish` job runs a version/tag assertion **after the +stamp step and before** `poetry publish --build`. It fails the job if any of the +four literals disagrees with the tag, **or if a literal has gone missing +entirely** — a missing literal is the silent-no-op class and is treated as a hard +failure, not a skip. Every value and the tag are printed on failure. Keep the +order **stamp → assert → publish**. + +`manual-publish.yml` is the recovery path for a one-off republish; it asserts the +built artifact filename against an explicit `expected_version` input. + +## Merging a release PR + +- The repo allows **squash merge only** (merge commits and rebase are disabled). + Squash uses `PR_TITLE` + `PR_BODY`, so the **PR body becomes the commit message + on `main`** — that is the only thing release-please parses. +- A `Release-As: X.Y.Z` footer must therefore live in the **PR body**, as the + final line, standalone, unindented and without backticks. In a commit message + trailer position it is ignored if indented or fenced. +- `BREAKING CHANGE:` footers belong in the PR body too, for the same reason. + +## If a release has already gone wrong + +- **Wrong version on PyPI:** it is permanent. Fix the version wiring, cut a NEW + correct version, and **yank** the bad one (human, via + `https://pypi.org/manage/project/speechify-api/release//`). +- **Tag points at a bad commit:** the tag/GitHub release can be recreated on the + corrected commit (public history mutation — back up the old SHA + notes first). +- Never delete a PyPI release to "reuse" the number. You can't. + +## Postmortem — the 3.0.0 release (why this file exists) + +A routine regeneration was pushed straight to publish. Failures, in order: + +1. Breaking-change PRs were admin-merged past the review gate. +2. Nothing bumped the version. `git show 2f19983 --stat` is the evidence: the + release commit touched **only** `.release-please-manifest.json` and + `CHANGELOG.md`. Both updaters had failed silently, for two different reasons: + - `pyproject.toml` was already `dynamic = ["version"]`, so the default `python` + updater logged `Skipping update` and left `[tool.poetry].version` at `2.0.1`. + The config had no `extra-files` TOML entry to catch it. + - the `generic` entry for `client_wrapper.py` was configured but the file had + no `x-release-please-version` marker, so it no-opped without erroring. +3. release-please tagged `3.0.0`; `poetry build` read `[tool.poetry].version` and + produced `2.0.1` → **`speechify-api 2.0.1` was published to PyPI** (immutable), + carrying the 3.0.0 breaking code under a patch number. +4. Each stale version string was found reactively, one at a time, instead of via + one exhaustive version-surface audit. + +The follow-up fix at the time was to make `[project].version` static. **That fix +does not survive a Fern regeneration** — the next regen restored +`dynamic = ["version"]` and re-armed the trap. Adding an `x-release-please-version` +marker to `client_wrapper.py` was the same mistake wearing a different hat: also +inside a generated file, also stripped by the next regen, also silent. Both are +gone now. + +Durable fixes live in exactly three places: `.fernignore`-protected files +(`release-please-config.json`), values derived from the tag at publish time (the +stamp step), or an assertion that fails the build (the publish assertion). If a +proposed fix is a literal written into a generated file, it is not durable — +it is a countdown to the next regeneration. + +Lesson: **audit the whole release surface first, dry-run, get sign-off, then +publish.** Treat one stale version string as a signal to check every other one, +and never rely on a fix that lives in a generated file. diff --git a/release-please-config.json b/release-please-config.json index 89ee893..05c2be3 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,19 +7,15 @@ ".": { "package-name": "speechify-api", "extra-files": [ - { - "type": "toml", - "path": "pyproject.toml", - "jsonpath": "$.project.version" - }, { "type": "toml", "path": "pyproject.toml", "jsonpath": "$.tool.poetry.version" }, { - "type": "generic", - "path": "src/speechify/core/client_wrapper.py" + "type": "json", + "path": ".fern/metadata.json", + "jsonpath": "$.sdkVersion" } ] }