diff --git a/.github/scripts/check_distributions.py b/.github/scripts/check_distributions.py new file mode 100644 index 0000000..653c8cb --- /dev/null +++ b/.github/scripts/check_distributions.py @@ -0,0 +1,50 @@ +"""Smoke-test wheel and sdist installations.""" + +# ruff: noqa: INP001, S603, S607 - Standalone helper using explicit uv arguments. + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def check_distributions(distributions: Path, version: str) -> None: + """Check both distributions in isolated environments.""" + wheels, sdists = list(distributions.glob("*.whl")), list(distributions.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ValueError("Expected exactly one wheel and one source distribution") + with tempfile.TemporaryDirectory(prefix="typesafe-distributions-") as temporary: + consumer = Path(temporary) + installation_check = consumer / "check_installed_distribution.py" + shutil.copyfile(Path(__file__).with_name("check_installed_distribution.py"), installation_check) + subprocess.run( + ["uv", "build", "--wheel", str(sdists[0].resolve()), "--out-dir", str(consumer / "rebuilt")], + cwd=consumer, + check=True, + ) + rebuilt = list((consumer / "rebuilt").glob("*.whl")) + if len(rebuilt) != 1: + raise ValueError("Expected one wheel rebuilt from the source distribution") + for wheel in [wheels[0].resolve(), rebuilt[0]]: + subprocess.run( + [ + "uv", + "run", + "--isolated", + "--no-project", + "--python", + sys.executable, + "--with", + str(wheel), + "python", + str(installation_check), + version, + ], + cwd=consumer, + check=True, + ) + + +if __name__ == "__main__": + check_distributions(Path(sys.argv[1]), sys.argv[2]) diff --git a/.github/scripts/check_installed_distribution.py b/.github/scripts/check_installed_distribution.py new file mode 100644 index 0000000..e16697b --- /dev/null +++ b/.github/scripts/check_installed_distribution.py @@ -0,0 +1,26 @@ +"""Check an installed SDK distribution.""" + +# ruff: noqa: INP001 - Standalone CI script. + +import asyncio +import sys +from importlib.metadata import version + +import typesafe_sdk +from typesafe_sdk import AsyncTypeSafeClient, TypeSafeClient + + +async def main() -> None: + """Check the installed version and initialize both clients.""" + if version("typesafe-sdk") != sys.argv[1]: + raise RuntimeError("Installed distribution version does not match the release") + if typesafe_sdk.__version__ != sys.argv[1]: + raise RuntimeError("Exported SDK version does not match the release") + with TypeSafeClient(api_key="release-smoke-test"): + pass + async with AsyncTypeSafeClient(api_key="release-smoke-test"): + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/.github/scripts/release_notes.py b/.github/scripts/release_notes.py new file mode 100644 index 0000000..244fbc6 --- /dev/null +++ b/.github/scripts/release_notes.py @@ -0,0 +1,43 @@ +"""Extract a tagged release's title and notes from the changelog.""" + +# ruff: noqa: INP001 - Standalone CI script. + +import os +import re +import sys +from datetime import date +from pathlib import Path + + +def release_notes(changelog: str, tag: str) -> tuple[str, str]: + """Return the latest release heading and body, requiring a unique entry matching the tag.""" + sections = re.split(r"^## ", changelog, flags=re.MULTILINE)[1:] + sections = [section for section in sections if section.partition("\n")[0] != "Unreleased"] + matches = [section for section in sections if section.partition("\n")[0].startswith(f"{tag} (")] + if len(matches) != 1: + raise ValueError(f"Expected exactly one changelog entry for {tag}, found {len(matches)}") + if matches[0] != sections[0]: + raise ValueError(f"Tag {tag} must match the latest changelog entry") + title, _, body = matches[0].partition("\n") + match = re.fullmatch(rf"{re.escape(tag)} \((\d{{4}}-\d{{2}}-\d{{2}})\)", title) + if match is None: + raise ValueError(f"Invalid release heading: {title}") + date.fromisoformat(match[1]) + if not body.strip(): + raise ValueError(f"Empty release notes for {tag}") + return title, body.strip() + "\n" + + +def main() -> None: + """Validate the tag and write notes and GitHub Actions outputs.""" + tag, version = sys.argv[1:] + if re.fullmatch(r"v\d+\.\d+\.\d+", tag) is None or tag != f"v{version}": + raise ValueError(f"Tag {tag} does not match package version {version} or the vX.Y.Z format") + title, notes = release_notes(Path("docs/changelog.md").read_text(), tag) + Path("release-notes.md").write_text(notes) + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"title={title}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cc8f13e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,114 @@ +name: Publish + +on: + push: + tags: ["v[0-9]*"] + workflow_dispatch: + inputs: + target: + description: Release target + type: choice + options: [dry-run, testpypi, pypi] + default: dry-run + +concurrency: + group: publish-${{ github.event_name == 'push' && 'pypi' || inputs.target }} + cancel-in-progress: false + +permissions: {} + +jobs: + build: + name: Build public distributions + if: github.repository == 'typesafe-ai/typesafe-sdk-python' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + title: ${{ steps.notes.outputs.title }} + tag: ${{ steps.notes.outputs.tag }} + target: ${{ github.event_name == 'push' && 'pypi' || inputs.target }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.5" + enable-cache: false + - name: Validate release and extract changelog notes + id: notes + env: + TARGET: ${{ github.event_name == 'push' && 'pypi' || inputs.target }} + REPOSITORY_PRIVATE: ${{ github.event.repository.private }} + run: | + case "$TARGET" in + dry-run|testpypi|pypi) ;; + *) echo 'Unknown publishing target'; exit 1 ;; + esac + TAG="v$(uv version --short)" + if [ "$TARGET" != dry-run ]; then + test "$REPOSITORY_PRIVATE" = false || { echo 'Use dry-run until this repository is public'; exit 1; } + git merge-base --is-ancestor HEAD origin/main || { echo 'Publish only source merged into public main'; exit 1; } + fi + if [ "$TARGET" = pypi ]; then + test "$GITHUB_REF_TYPE" = tag || { echo 'Select a public version tag for PyPI'; exit 1; } + fi + if [ "$GITHUB_REF_TYPE" = tag ]; then TAG="$GITHUB_REF_NAME"; fi + uv run --no-project .github/scripts/release_notes.py "$TAG" "$(uv version --short)" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Build and test distributions + run: | + uv build + uv run --no-project .github/scripts/check_distributions.py dist "$(uv version --short)" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release + path: | + dist/ + release-notes.md + if-no-files-found: error + + publish: + name: Publish to ${{ needs.build.outputs.target }} + needs: build + if: needs.build.outputs.target != 'dry-run' + runs-on: ubuntu-latest + environment: + name: ${{ needs.build.outputs.target }} + permissions: + id-token: write # Trusted Publishing and attestations. + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release + - name: Publish tested distributions + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: ${{ needs.build.outputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} + + github-release: + name: GitHub release + needs: [build, publish] + if: needs.build.outputs.target == 'pypi' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TITLE: ${{ needs.build.outputs.title }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + run: | + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" --title "$RELEASE_TITLE" --notes-file release-notes.md + else + gh release create "$RELEASE_TAG" --verify-tag --title "$RELEASE_TITLE" --notes-file release-notes.md + fi + gh release upload "$RELEASE_TAG" dist/* --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61d569f --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +.env +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +.pytest_cache/ +.ruff_cache/ +.cache/ +site/ + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +# Release automation output +release-notes.md diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..0afddee --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,10 @@ +--- +title: Changelog +icon: lucide/history +--- + +# Changelog + +## v0.5.7 (2026-09-14) + +This is the initial public release of TypeSafe Python SDK. Learn more in the [documentation](https://docs.typesafe.ai/sdk/python). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ca72c81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "typesafe-sdk" +version = "0.5.7" +description = "Python SDK for TypeSafe AI API." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [ + { name = "TypeSafe AI", email = "support@typesafe.ai" } +] +maintainers = [ + { name = "Daniel Gafni", email = "daniel@typesafe.ai" } +] +requires-python = ">=3.10" +keywords = ["typesafe", "sdk", "api", "ai"] +classifiers = [ + "Typing :: Typed", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "httpx2>=2.0.0", + "msgspec>=0.21.1", + "tenacity>=9.0.0", + "typing-extensions>=4.13.0", +] + +[project.urls] +Homepage = "https://typesafe.ai" +Documentation = "https://docs.typesafe.ai/sdk/python/" +Changelog = "https://docs.typesafe.ai/sdk/python/changelog/" +Repository = "https://github.com/typesafe-ai/typesafe-sdk-python" +Issues = "https://github.com/typesafe-ai/typesafe-sdk-python/issues" + +[build-system] +requires = ["uv_build>=0.12.5,<0.13"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "gitpython>=3.1.62", + "pyrefly>=1.2.0", + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", + "ruff>=0.16.6", + "sybil>=9.3.0", + "syrupy>=6.0.0", + "tach>=0.35.0", +] +docs = [ + "mkdocstrings[python]>=1.0.6", + "ruff>=0.16.6", + "zensical>=0.0.60", +] +codegen = [ + "datamodel-code-generator>=0.79.0", +] + +[tool.pytest.ini_options] +addopts = "-vv -ra --capture=tee-sys -p no:tach" +log_cli = true +log_cli_level = "INFO" +log_level = "INFO" +asyncio_mode = "auto" +testpaths = ["tests"] +markers = ["integration: live API tests requiring TYPESAFE_API_KEY"] + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/pyrefly.toml b/pyrefly.toml new file mode 100644 index 0000000..b848fb5 --- /dev/null +++ b/pyrefly.toml @@ -0,0 +1,9 @@ +preset = "strict" +project-includes = [ + "src", + "tests", +] +# Negative fixtures are checked by pytest with --expectations. +project-excludes = ["tests/typing/negative"] +python-version = "3.10" +search-path = ["src", "."] diff --git a/src/typesafe_sdk/__init__.py b/src/typesafe_sdk/__init__.py new file mode 100644 index 0000000..efc81a3 --- /dev/null +++ b/src/typesafe_sdk/__init__.py @@ -0,0 +1,89 @@ +"""Python clients and public types for TypeSafe AI.""" + +from typesafe_sdk import _core as _core +from typesafe_sdk import constants as constants +from typesafe_sdk._core.client.aio.client import AsyncTypeSafeClient +from typesafe_sdk._core.client.aio.models import AsyncModels +from typesafe_sdk._core.client.sync.client import TypeSafeClient +from typesafe_sdk._core.client.sync.models import Models +from typesafe_sdk._core.errors import ( + TypeSafeAPIConnectionError, + TypeSafeAPIError, + TypeSafeAPIResponseValidationError, + TypeSafeAPITimeoutError, + TypeSafeAuthenticationError, + TypeSafeBadRequestError, + TypeSafeError, + TypeSafeInternalServerError, + TypeSafeNotFoundError, + TypeSafePermissionDeniedError, + TypeSafeRateLimitError, + TypeSafeUnprocessableEntityError, +) +from typesafe_sdk._core.json_types import JSONValue +from typesafe_sdk._core.question_types import ( + Choice, + ChoiceModel, + Noul, + NoulCriteria, + NoulModel, + Question, + QuestionModel, + Questions, + Score, + ScoreModel, +) +from typesafe_sdk._core.response_types import ( + Answer, + ChoiceAnswer, + ListModelsResponse, + ModelMetadata, + NoulAnswer, + ScoreAnswer, + SystemOneResponse, + Usage, +) +from typesafe_sdk._core.retry import RetryPolicy +from typesafe_sdk._version import __version__ as __version__ + +__all__ = [ + "Answer", + "AsyncModels", + "AsyncTypeSafeClient", + "Choice", + "ChoiceAnswer", + "ChoiceModel", + "JSONValue", + "ListModelsResponse", + "ModelMetadata", + "Models", + "Noul", + "NoulAnswer", + "NoulCriteria", + "NoulModel", + "Question", + "QuestionModel", + "Questions", + "RetryPolicy", + "Score", + "ScoreAnswer", + "ScoreModel", + "SystemOneResponse", + "TypeSafeAPIConnectionError", + "TypeSafeAPIError", + "TypeSafeAPIResponseValidationError", + "TypeSafeAPITimeoutError", + "TypeSafeAuthenticationError", + "TypeSafeBadRequestError", + "TypeSafeClient", + "TypeSafeError", + "TypeSafeInternalServerError", + "TypeSafeNotFoundError", + "TypeSafePermissionDeniedError", + "TypeSafeRateLimitError", + "TypeSafeUnprocessableEntityError", + "Usage", + "constants", +] + +del _core diff --git a/src/typesafe_sdk/_core/__init__.py b/src/typesafe_sdk/_core/__init__.py new file mode 100644 index 0000000..678630a --- /dev/null +++ b/src/typesafe_sdk/_core/__init__.py @@ -0,0 +1 @@ +"""Private implementation details.""" diff --git a/src/typesafe_sdk/_core/client/__init__.py b/src/typesafe_sdk/_core/client/__init__.py new file mode 100644 index 0000000..2c226ea --- /dev/null +++ b/src/typesafe_sdk/_core/client/__init__.py @@ -0,0 +1 @@ +"""Synchronous and asynchronous clients and their resource accessors.""" diff --git a/src/typesafe_sdk/_core/client/aio/__init__.py b/src/typesafe_sdk/_core/client/aio/__init__.py new file mode 100644 index 0000000..11186ff --- /dev/null +++ b/src/typesafe_sdk/_core/client/aio/__init__.py @@ -0,0 +1 @@ +"""Asynchronous client and resource accessors.""" diff --git a/src/typesafe_sdk/_core/client/aio/client.py b/src/typesafe_sdk/_core/client/aio/client.py new file mode 100644 index 0000000..c1d2ed7 --- /dev/null +++ b/src/typesafe_sdk/_core/client/aio/client.py @@ -0,0 +1,194 @@ +"""Asynchronous API client.""" + +from collections.abc import Mapping +from functools import cached_property +from types import TracebackType + +import httpx2 +from typing_extensions import Self + +from typesafe_sdk._core.client.aio.models import AsyncModels +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.endpoints import prepare_system_one +from typesafe_sdk._core.json_types import JSONValue +from typesafe_sdk._core.question_types import Question +from typesafe_sdk._core.response_types import SystemOneResponse +from typesafe_sdk._core.retry import RetryPolicy, build_tenacity_async +from typesafe_sdk._core.transport import Request, ResponseT, send_async + + +class AsyncTypeSafeClient: + def __init__( + self, + *, + api_key: str | None = None, + model: str | None = None, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + headers: Mapping[str, str] | None = None, + transport: httpx2.AsyncBaseTransport | None = None, + http_client: httpx2.AsyncClient | None = None, + base_url: str | None = None, + ) -> None: + """Create an asynchronous HTTP client for [TypeSafe AI API](https://typesafe.ai). + + Explicit options take precedence over environment variables; empty or whitespace-only + environment values are ignored. + + !!! tip "Logging setup" + The SDK logs to the `typesafe_sdk` logger; configure it through standard logging, or set + `TYPESAFE_LOG_LEVEL` (`debug`, `info`, ...) for a quick default. Secret headers are + redacted from log output; request and response bodies are not. + + Args: + api_key: Required API key; may be set via the `TYPESAFE_API_KEY` environment variable. + model: Model name; may be set via the `TYPESAFE_DEFAULT_MODEL` environment variable. + retry: A `RetryPolicy` controlling retry behavior; see `RetryPolicy` for the available options and their + defaults. Pass `RetryPolicy(max_retries=0)` to disable retries. + timeout: Timeout for HTTP operations. Inherits `http_client.timeout` when supplied, otherwise the SDK default. + headers: Additional request headers to set. + transport: Optional custom HTTP transport, closed when this SDK client closes. + http_client: Optional `httpx2.AsyncClient`; mutually exclusive with `transport`. + Closed when this SDK client closes. + base_url: API root; may be set via the `TYPESAFE_BASE_URL` environment variable. + + Raises: + TypeSafeError: The API key is missing or the timeout is invalid. + ValueError: Both `transport` and `http_client` are supplied. + + Examples: + ```python + import asyncio + + from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul + + + async def main() -> None: + async with AsyncTypeSafeClient() as client: + result = await client.system_one( + state="I was charged twice. Please help.", + questions={ + "billing": Noul(instructions="Is this about billing?"), + "tone": Choice( + instructions="What is the tone?", + criteria={"calm": None, "angry": None}, + ), + }, + ) + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "angry"} + + + asyncio.run(main()) + ``` + """ + if transport is not None and http_client is not None: + raise ValueError("transport and http_client are mutually exclusive.") + if timeout is None and http_client is not None: + timeout = http_client.timeout + self._config = Config.resolve(api_key, base_url, model, timeout, headers) + self._retry = build_tenacity_async(retry) + self._http_client = httpx2.AsyncClient(timeout=self._config.timeout, transport=transport) if http_client is None else http_client + + @cached_property + def models(self) -> AsyncModels: + """An accessor for the Models API resource. + + Examples: + ```python + async def main() -> None: + async with AsyncTypeSafeClient() as client: + models = await client.models.list() + ``` + """ + return AsyncModels(self._config, self._http_client, self._retry) + + async def system_one( + self, + state: str | dict[str, JSONValue | None] | list[JSONValue | None], + questions: Mapping[str, Question], + *, + model: str | None = None, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + extra_headers: Mapping[str, str] | None = None, + extra_body: Mapping[str, JSONValue | None] | None = None, + ) -> SystemOneResponse: + """Answer named questions about text or structured state. + + Args: + state: Text, a JSON object, or an array to evaluate; not `None`. + questions: Nonempty mapping of names to question objects or raw dictionaries. + model: Model override; `None` inherits the client default. + retry: An optional retry policy to override the client-level value for this call only. + timeout: An optional timeout for http operations to override the client-level value for this call only, in seconds. + extra_headers: Additional request headers to set. + extra_body: Additional top-level request-body fields, shallow-merged over the body after + `state`, `model`, and `questions` are set. Merging is last-write-wins: a key that + collides with `state`, `model`, or `questions` overrides it, and object values are + replaced rather than deep-merged. + + Returns: + Answers keyed by question name, with model and token usage details. + + Raises: + TypeSafeError: Questions are empty or score criteria are empty or not indexed consecutively from zero. + TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. + TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. + + Examples: + Create questions with named arguments: + + ```python + async def main() -> None: + async with AsyncTypeSafeClient() as client: + result = await client.system_one( + state="I was charged twice. Please help.", + questions={ + "billing": Noul(instructions="Is this about billing?"), + "tone": Choice( + instructions="What is the tone?", + criteria={"calm": None, "angry": None}, + ), + }, + ) + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "angry"} + ``` + + Pass questions as dictionaries: + + ```python + async def main() -> None: + async with AsyncTypeSafeClient() as client: + result = await client.system_one( + state={"message": "I was charged twice. Please help."}, + questions={ + "billing": {"type": "noul", "instructions": "Is this about billing?"}, + "tone": { + "type": "choice", + "instructions": "What is the tone?", + "criteria": {"calm": None, "angry": None}, + }, + }, + ) + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "angry"} + ``` + """ + return await self._request(prepare_system_one(self._config, state, questions, model, extra_body, timeout, extra_headers), retry=retry) + + async def _request(self, request: Request[ResponseT], *, retry: RetryPolicy | None = None) -> ResponseT: + return await send_async(self._http_client, self._retry, request, retry) + + async def aclose(self) -> None: + """Release network resources and close the underlying HTTP client, including a supplied one.""" + await self._http_client.aclose() + + async def __aenter__(self) -> Self: + """Enter the asynchronous client context.""" + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: + """Close the asynchronous client context.""" + await self.aclose() diff --git a/src/typesafe_sdk/_core/client/aio/models.py b/src/typesafe_sdk/_core/client/aio/models.py new file mode 100644 index 0000000..277a4c2 --- /dev/null +++ b/src/typesafe_sdk/_core/client/aio/models.py @@ -0,0 +1,56 @@ +"""Model resource for the asynchronous client, exposed as `AsyncTypeSafeClient.models`.""" + +from collections.abc import Mapping + +import httpx2 +from tenacity import AsyncRetrying + +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.endpoints import prepare_models +from typesafe_sdk._core.response_types import ListModelsResponse +from typesafe_sdk._core.retry import RetryPolicy +from typesafe_sdk._core.transport import send_async + + +class AsyncModels: + """Access to the models available to the account, reached through `AsyncTypeSafeClient.models`.""" + + def __init__(self, config: Config, http_client: httpx2.AsyncClient, retry: AsyncRetrying) -> None: + self._config = config + self._http_client = http_client + self._retry = retry + + async def list( + self, + *, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> ListModelsResponse: + """List the models available to the account. + + Args: + retry: An optional retry policy to override the client-level value for this call only. + timeout: Per-operation timeout override; `None` inherits the client setting. + extra_headers: Overrides for additional request headers; authentication, SDK identification, + and `Accept` remain protected. + + Returns: + A `ListModelsResponse` whose `models` holds each model's name, description, + and release date. + + Raises: + TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. + TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. + + Examples: + ```python + from typesafe_sdk import AsyncTypeSafeClient + + + async def main() -> None: + async with AsyncTypeSafeClient() as client: + models = await client.models.list() + ``` + """ + return await send_async(self._http_client, self._retry, prepare_models(self._config, timeout, extra_headers), retry) diff --git a/src/typesafe_sdk/_core/client/sync/__init__.py b/src/typesafe_sdk/_core/client/sync/__init__.py new file mode 100644 index 0000000..6baea3a --- /dev/null +++ b/src/typesafe_sdk/_core/client/sync/__init__.py @@ -0,0 +1 @@ +"""Synchronous client and resource accessors.""" diff --git a/src/typesafe_sdk/_core/client/sync/client.py b/src/typesafe_sdk/_core/client/sync/client.py new file mode 100644 index 0000000..4be767b --- /dev/null +++ b/src/typesafe_sdk/_core/client/sync/client.py @@ -0,0 +1,184 @@ +"""Synchronous API client.""" + +from collections.abc import Mapping +from functools import cached_property +from types import TracebackType + +import httpx2 +from typing_extensions import Self + +from typesafe_sdk._core.client.sync.models import Models +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.endpoints import prepare_system_one +from typesafe_sdk._core.json_types import JSONValue +from typesafe_sdk._core.question_types import Question +from typesafe_sdk._core.response_types import SystemOneResponse +from typesafe_sdk._core.retry import RetryPolicy, build_tenacity +from typesafe_sdk._core.transport import Request, ResponseT, send + + +class TypeSafeClient: + def __init__( + self, + *, + api_key: str | None = None, + model: str | None = None, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + headers: Mapping[str, str] | None = None, + transport: httpx2.BaseTransport | None = None, + http_client: httpx2.Client | None = None, + base_url: str | None = None, + ) -> None: + """Create an HTTP client for [TypeSafe AI API](https://typesafe.ai). + + Explicit options take precedence over environment variables; empty or whitespace-only + environment values are ignored. + + !!! tip "Logging setup" + The SDK logs to the `typesafe_sdk` logger; configure it through standard logging, or set + `TYPESAFE_LOG_LEVEL` (`debug`, `info`, ...) for a quick default. Secret headers are + redacted from log output; request and response bodies are not. + + Args: + api_key: Required API key; may be set via the `TYPESAFE_API_KEY` environment variable. + model: Model name; may be set via the `TYPESAFE_DEFAULT_MODEL` environment variable. + retry: A `RetryPolicy` controlling retry behavior; see `RetryPolicy` for the available options and their + defaults. Pass `RetryPolicy(max_retries=0)` to disable retries. + timeout: Timeout for HTTP operations. Inherits `http_client.timeout` when supplied, otherwise the SDK default. + headers: Additional request headers to set. + transport: Optional custom HTTP transport, closed when this SDK client closes. + http_client: Optional `httpx2.Client`; mutually exclusive with `transport`. + Closed when this SDK client closes. + base_url: API root; may be set via the `TYPESAFE_BASE_URL` environment variable. + + Raises: + TypeSafeError: The API key is missing or the timeout is invalid. + ValueError: Both `transport` and `http_client` are supplied. + + Examples: + ```python + from typesafe_sdk import Choice, Noul, TypeSafeClient + + with TypeSafeClient() as client: + result = client.system_one( + state="I was charged twice. Please help.", + questions={ + "billing": Noul(instructions="Is this about billing?"), + "tone": Choice( + instructions="What is the tone?", + criteria={"calm": None, "angry": None}, + ), + }, + ) + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "angry"} + ``` + """ + if transport is not None and http_client is not None: + raise ValueError("transport and http_client are mutually exclusive.") + if timeout is None and http_client is not None: + timeout = http_client.timeout + self._config = Config.resolve(api_key, base_url, model, timeout, headers) + self._retry = build_tenacity(retry) + self._http_client = httpx2.Client(timeout=self._config.timeout, transport=transport) if http_client is None else http_client + + @cached_property + def models(self) -> Models: + """An accessor for the Models API resource. + + Examples: + ```python + with TypeSafeClient() as client: + models = client.models.list() + ``` + """ + return Models(self._config, self._http_client, self._retry) + + def system_one( + self, + state: str | dict[str, JSONValue | None] | list[JSONValue | None], + questions: Mapping[str, Question], + *, + model: str | None = None, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + extra_headers: Mapping[str, str] | None = None, + extra_body: Mapping[str, JSONValue | None] | None = None, + ) -> SystemOneResponse: + """Answer named questions about text or structured state. + + Args: + state: Text, a JSON object, or an array to evaluate; not `None`. + questions: Nonempty mapping of names to question objects or raw dictionaries. + model: Model override; `None` inherits the client default. + retry: An optional retry policy to override the client-level value for this call only. + timeout: An optional timeout for http operations to override the client-level value for this call only, in seconds. + extra_headers: Additional request headers to set. + extra_body: Additional top-level request-body fields, shallow-merged over the body after + `state`, `model`, and `questions` are set. Merging is last-write-wins: a key that + collides with `state`, `model`, or `questions` overrides it, and object values are + replaced rather than deep-merged. + + Returns: + Answers keyed by question name, with model and token usage details. + + Raises: + TypeSafeError: Questions are empty or score criteria are empty or not indexed consecutively from zero. + TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. + TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. + + Examples: + Create questions with named arguments: + + ```python + with TypeSafeClient() as client: + result = client.system_one( + state="I was charged twice. Please help.", + questions={ + "billing": Noul(instructions="Is this about billing?"), + "tone": Choice( + instructions="What is the tone?", + criteria={"calm": None, "angry": None}, + ), + }, + ) + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "angry"} + ``` + + Pass questions as dictionaries: + + ```python + with TypeSafeClient() as client: + result = client.system_one( + state={"message": "I was charged twice. Please help."}, + questions={ + "billing": {"type": "noul", "instructions": "Is this about billing?"}, + "tone": { + "type": "choice", + "instructions": "What is the tone?", + "criteria": {"calm": None, "angry": None}, + }, + }, + ) + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "angry"} + ``` + """ + return self._request(prepare_system_one(self._config, state, questions, model, extra_body, timeout, extra_headers), retry=retry) + + def _request(self, request: Request[ResponseT], *, retry: RetryPolicy | None = None) -> ResponseT: + return send(self._http_client, self._retry, request, retry) + + def close(self) -> None: + """Release network resources and close the underlying HTTP client, including a supplied one.""" + self._http_client.close() + + def __enter__(self) -> Self: + """Enter the client context.""" + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: + """Close the client context.""" + self.close() diff --git a/src/typesafe_sdk/_core/client/sync/models.py b/src/typesafe_sdk/_core/client/sync/models.py new file mode 100644 index 0000000..816f58d --- /dev/null +++ b/src/typesafe_sdk/_core/client/sync/models.py @@ -0,0 +1,54 @@ +"""Model resource for the synchronous client, exposed as `TypeSafeClient.models`.""" + +from collections.abc import Mapping + +import httpx2 +from tenacity import Retrying + +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.endpoints import prepare_models +from typesafe_sdk._core.response_types import ListModelsResponse +from typesafe_sdk._core.retry import RetryPolicy +from typesafe_sdk._core.transport import send + + +class Models: + """Access to the models available to the account, reached through `TypeSafeClient.models`.""" + + def __init__(self, config: Config, http_client: httpx2.Client, retry: Retrying) -> None: + self._config = config + self._http_client = http_client + self._retry = retry + + def list( + self, + *, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> ListModelsResponse: + """List the models available to the account. + + Args: + retry: An optional retry policy to override the client-level value for this call only. + timeout: Per-operation timeout override; `None` inherits the client setting. + extra_headers: Overrides for additional request headers; authentication, SDK identification, + and `Accept` remain protected. + + Returns: + A `ListModelsResponse` whose `models` holds each model's name, description, + and release date. + + Raises: + TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. + TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. + + Examples: + ```python + from typesafe_sdk import TypeSafeClient + + with TypeSafeClient() as client: + models = client.models.list() + ``` + """ + return send(self._http_client, self._retry, prepare_models(self._config, timeout, extra_headers), retry) diff --git a/src/typesafe_sdk/_core/config.py b/src/typesafe_sdk/_core/config.py new file mode 100644 index 0000000..3d0b5a5 --- /dev/null +++ b/src/typesafe_sdk/_core/config.py @@ -0,0 +1,61 @@ +"""Configuration resolution.""" + +import math +import os +from collections.abc import Mapping +from dataclasses import dataclass, field + +import httpx2 + +from typesafe_sdk._core.errors import TypeSafeError +from typesafe_sdk.constants import ( + API_KEY_ENV, + BASE_URL_ENV, + DEFAULT_BASE_URL, + DEFAULT_MODEL, + DEFAULT_MODEL_ENV, + DEFAULT_TIMEOUT, +) + + +def _resolve_env(value: str | None, env: str, default: str | None = None) -> str | None: + return value if value is not None else os.environ.get(env, "").strip() or default + + +def resolve_timeout(timeout: float | httpx2.Timeout) -> float | httpx2.Timeout: + if not isinstance(timeout, httpx2.Timeout) and (not math.isfinite(timeout) or timeout <= 0): + raise TypeSafeError("timeout must be a positive, finite number of seconds.") + return timeout + + +@dataclass +class Config: + api_key: str = field(repr=False) + base_url: str + default_model: str + timeout: float | httpx2.Timeout + default_headers: httpx2.Headers = field(repr=False) + + @classmethod + def resolve( + cls, + api_key: str | None, + base_url: str | None, + default_model: str | None, + timeout: float | httpx2.Timeout | None, + default_headers: Mapping[str, str] | None, + ) -> "Config": + key = _resolve_env(api_key, API_KEY_ENV) + if key is None: + raise TypeSafeError(f"No API key was provided. Pass api_key or set the {API_KEY_ENV} environment variable.") + resolved_base_url = _resolve_env(base_url, BASE_URL_ENV, DEFAULT_BASE_URL) + resolved_model = _resolve_env(default_model, DEFAULT_MODEL_ENV, DEFAULT_MODEL) + assert resolved_base_url is not None, "DEFAULT_BASE_URL default guarantees a value" # noqa: S101 - narrows the non-None default. + assert resolved_model is not None, "DEFAULT_MODEL default guarantees a value" # noqa: S101 - narrows the non-None default. + return cls( + key, + resolved_base_url.rstrip("/"), + resolved_model, + resolve_timeout(DEFAULT_TIMEOUT if timeout is None else timeout), + httpx2.Headers(default_headers), + ) diff --git a/src/typesafe_sdk/_core/constants.py b/src/typesafe_sdk/_core/constants.py new file mode 100644 index 0000000..99901be --- /dev/null +++ b/src/typesafe_sdk/_core/constants.py @@ -0,0 +1,21 @@ +"""Internal protocol and logging constants.""" + +MAX_ERROR_BODY_LENGTH = 200 + +SYSTEM_ONE_PATH = "/v1/systemone" +MODELS_PATH = "/v1/models" +SDK_NAME = "typesafe-sdk" +LOGGER_NAME = "typesafe_sdk" +JSON_CONTENT_TYPE = "application/json" + +AUTHORIZATION_HEADER = "Authorization" +ACCEPT_HEADER = "Accept" +CONTENT_TYPE_HEADER = "Content-Type" +USER_AGENT_HEADER = "User-Agent" +SDK_HEADER = "X-TypeSafe-SDK" +RUNTIME_HEADER = "X-TypeSafe-Runtime" +RETRY_COUNT_HEADER = "X-TypeSafe-Retry-Count" +REQUEST_ID_HEADER = "x-typesafe-request-id" +RETRY_AFTER_HEADER = "retry-after" +RETRY_AFTER_MS_HEADER = "retry-after-ms" +SECRET_HEADERS = frozenset({"authorization", "proxy-authorization", "x-api-key", "api-key", "cookie", "set-cookie"}) diff --git a/src/typesafe_sdk/_core/endpoints.py b/src/typesafe_sdk/_core/endpoints.py new file mode 100644 index 0000000..099fecf --- /dev/null +++ b/src/typesafe_sdk/_core/endpoints.py @@ -0,0 +1,37 @@ +"""Endpoint-specific request builders, layered over the generic `transport.prepare`.""" + +from collections.abc import Mapping +from typing import Any + +import httpx2 + +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.constants import MODELS_PATH, SYSTEM_ONE_PATH +from typesafe_sdk._core.json_types import JSONValue +from typesafe_sdk._core.question_types import Question +from typesafe_sdk._core.questions import normalize_questions +from typesafe_sdk._core.response_types import ListModelsResponse, SystemOneResponse +from typesafe_sdk._core.transport import Request, prepare + + +def prepare_system_one( + config: Config, + state: str | dict[str, JSONValue | None] | list[JSONValue | None], + questions: Mapping[str, Question], + model: str | None, + extra_body: Mapping[str, JSONValue | None] | None, + timeout: float | httpx2.Timeout | None, + headers: Mapping[str, str] | None, +) -> Request[SystemOneResponse]: + body: dict[str, Any] = { + "state": state, + "model": config.default_model if model is None else model, + "questions": normalize_questions(questions), + } + if extra_body is not None: + body.update(extra_body) + return prepare(config, "POST", SYSTEM_ONE_PATH, body, timeout, headers, SystemOneResponse) + + +def prepare_models(config: Config, timeout: float | httpx2.Timeout | None, headers: Mapping[str, str] | None) -> Request[ListModelsResponse]: + return prepare(config, "GET", MODELS_PATH, None, timeout, headers, ListModelsResponse) diff --git a/src/typesafe_sdk/_core/errors.py b/src/typesafe_sdk/_core/errors.py new file mode 100644 index 0000000..ba69e31 --- /dev/null +++ b/src/typesafe_sdk/_core/errors.py @@ -0,0 +1,167 @@ +"""Exceptions and server message extraction.""" + +import math +import time +from email.utils import parsedate_to_datetime +from http import HTTPStatus +from typing import Any + +import httpx2 + +from typesafe_sdk._core.constants import MAX_ERROR_BODY_LENGTH, REQUEST_ID_HEADER, RETRY_AFTER_HEADER, RETRY_AFTER_MS_HEADER +from typesafe_sdk._core.json import serialize + + +def parse_retry_after(headers: httpx2.Headers) -> float | None: + for name, multiplier in ((RETRY_AFTER_MS_HEADER, 1), (RETRY_AFTER_HEADER, 1000)): + raw = headers.get(name) + if raw is None: + continue + try: + value = float(raw.strip() or "0") + except ValueError: + if name == RETRY_AFTER_HEADER: + try: + return max(0.0, (parsedate_to_datetime(raw).timestamp() - time.time()) * 1000) + except (ValueError, TypeError, OverflowError): + pass + else: + if math.isfinite(value): + if value >= 0: + return value * multiplier + elif name == RETRY_AFTER_HEADER: + return None + return None + + +def extract_message(body: Any) -> str | None: + if isinstance(body, str): + return body or None + elif not isinstance(body, dict): + return None + error, message, detail = body.get("error"), body.get("message"), body.get("detail") + if isinstance(error, str): + return error + elif isinstance(error, dict) and isinstance(error.get("message"), str): + return error["message"] + elif isinstance(message, str): + return message + elif isinstance(detail, str): + return detail + elif isinstance(detail, dict) and isinstance(detail.get("message"), str): + return detail["message"] + elif isinstance(detail, list): + parts = [] + for entry in detail: + if not isinstance(entry, dict) or not isinstance(entry.get("msg"), str): + continue + location = entry.get("loc") + path = ".".join(str(item) for item in location if item != "body") if isinstance(location, list) else "" + parts.append(f"{path}: {entry['msg']}" if path else entry["msg"]) + return "; ".join(parts) or None + return None + + +class TypeSafeError(Exception): + """Base exception for SDK failures.""" + + +class TypeSafeAPIError(TypeSafeError): + """An unsuccessful HTTP response with its body and request metadata.""" + + def __init__(self, status: int, body: Any, headers: httpx2.Headers, message: str | None = None) -> None: + """Describe an HTTP failure with an optional message override.""" + self.status = status + """HTTP response status code.""" + self.body = body + """The server's JSON error body, plain response text, or `None` for an empty body.""" + self.headers = headers + """HTTP response headers.""" + if message is None: + detail = extract_message(body) + if detail: + message = f"{status} {detail}" + elif body is None: + message = f"{status} status code (no body)" + else: + raw = body if isinstance(body, str) else serialize(body).decode() + message = f"{status} {raw[:MAX_ERROR_BODY_LENGTH] + '…' if len(raw) > MAX_ERROR_BODY_LENGTH else raw}" + super().__init__(message) + + @property + def request_id(self) -> str | None: + """The `x-typesafe-request-id` response header, or `None` if absent.""" + return self.headers.get(REQUEST_ID_HEADER) + + +class TypeSafeBadRequestError(TypeSafeAPIError): + """The request was invalid (400).""" + + +class TypeSafeAuthenticationError(TypeSafeAPIError): + """Authentication failed (401).""" + + +class TypeSafePermissionDeniedError(TypeSafeAPIError): + """Access was denied (403).""" + + +class TypeSafeNotFoundError(TypeSafeAPIError): + """The resource was not found (404).""" + + +class TypeSafeUnprocessableEntityError(TypeSafeAPIError): + """The request failed server validation (422).""" + + +class TypeSafeRateLimitError(TypeSafeAPIError): + """The rate limit was exceeded (429).""" + + def __init__(self, status: int, body: object, headers: httpx2.Headers, message: str | None = None) -> None: + """Describe a rate-limit response with an optional message override.""" + super().__init__(status, body, headers, message) + self.retry_after_ms = parse_retry_after(headers) + """The server's requested wait in milliseconds, or `None` if unavailable.""" + + +class TypeSafeInternalServerError(TypeSafeAPIError): + """The server failed to process the request (5xx).""" + + +class TypeSafeAPIConnectionError(TypeSafeError, ConnectionError): + """A request failed without an HTTP response.""" + + +class TypeSafeAPITimeoutError(TypeSafeAPIConnectionError, TimeoutError): + """A request exceeded its configured timeout.""" + + def __init__(self, timeout: float | httpx2.Timeout) -> None: + """Describe a request timeout with its configured timeout setting.""" + self.timeout = timeout + """The timeout setting used for the request, in seconds or as an `httpx2.Timeout`.""" + super().__init__(f"Request timed out (timeout={timeout}).") + + +class TypeSafeAPIResponseValidationError(TypeSafeAPIError): + """A successful HTTP response whose body was missing or structurally invalid required data.""" + + def __init__(self, status: int, body: Any, headers: httpx2.Headers, field_path: str) -> None: + """Describe an unparseable response, naming the first missing or structurally invalid field.""" + self.field_path = field_path + """Dotted path to the offending field, such as `answers.tone.confidence`.""" + super().__init__(status, body, headers, f"Invalid response data at {field_path!r}.") + + +STATUS_ERROR_TYPES: dict[int, type[TypeSafeAPIError]] = { + 400: TypeSafeBadRequestError, + 401: TypeSafeAuthenticationError, + 403: TypeSafePermissionDeniedError, + 404: TypeSafeNotFoundError, + 422: TypeSafeUnprocessableEntityError, + 429: TypeSafeRateLimitError, +} + + +def api_error(status: int, body: object, headers: httpx2.Headers) -> TypeSafeAPIError: + error_type = STATUS_ERROR_TYPES.get(status, TypeSafeInternalServerError if status >= HTTPStatus.INTERNAL_SERVER_ERROR else TypeSafeAPIError) + return error_type(status, body, headers) diff --git a/src/typesafe_sdk/_core/json.py b/src/typesafe_sdk/_core/json.py new file mode 100644 index 0000000..57fb496 --- /dev/null +++ b/src/typesafe_sdk/_core/json.py @@ -0,0 +1,18 @@ +"""JSON encoding and lenient response decoding.""" + +from typing import Any + +import msgspec + + +def serialize(value: object) -> bytes: + return msgspec.json.encode(value) + + +def deserialize(content: bytes) -> Any: + if not content: + return None + try: + return msgspec.json.decode(content) + except msgspec.DecodeError: + return content.decode("utf-8", errors="replace") diff --git a/src/typesafe_sdk/_core/json_types.py b/src/typesafe_sdk/_core/json_types.py new file mode 100644 index 0000000..017c687 --- /dev/null +++ b/src/typesafe_sdk/_core/json_types.py @@ -0,0 +1,6 @@ +"""Shared JSON value types.""" + +from typing import TypeAlias + +JSONValue: TypeAlias = str | int | float | bool | list["JSONValue | None"] | dict[str, "JSONValue | None"] +"""A non-null JSON value, with null permitted inside arrays and objects.""" diff --git a/src/typesafe_sdk/_core/logging.py b/src/typesafe_sdk/_core/logging.py new file mode 100644 index 0000000..b606717 --- /dev/null +++ b/src/typesafe_sdk/_core/logging.py @@ -0,0 +1,64 @@ +"""Standard-library logging with credential redaction. + +The SDK emits to the ``typesafe_sdk`` logger and never configures handlers or levels beyond an +optional convenience: set ``TYPESAFE_LOG_LEVEL`` (``debug``/``info``/...) and the level is applied +once at import. Otherwise configure the ``typesafe_sdk`` logger through standard logging as usual. +""" + +import logging +import os + +from typing_extensions import override + +from typesafe_sdk._core.constants import LOGGER_NAME, SECRET_HEADERS +from typesafe_sdk.constants import LOG_LEVEL_ENV + +logger = logging.getLogger(LOGGER_NAME) + + +LOG_LEVELS = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warn": logging.WARNING, + "warning": logging.WARNING, + "error": logging.ERROR, + "off": logging.CRITICAL + 1, +} + + +def _is_secret(name: str) -> bool: + lowered = name.lower() + return lowered in SECRET_HEADERS or "token" in lowered or "secret" in lowered + + +def _redact(headers: dict[str, str]) -> dict[str, str]: + return {name: "***" if _is_secret(name) else value for name, value in headers.items()} + + +class SensitiveHeadersFilter(logging.Filter): + """Redact credential-bearing headers from a record before it reaches any handler. + + Log calls pass headers as the ``headers`` key of a mapping-style ``args``; this filter is the + single redaction point, so no call site can leak a secret header. + """ + + @override + def filter(self, record: logging.LogRecord) -> bool: + args = record.args + if isinstance(args, dict): + headers = args.get("headers") + if isinstance(headers, dict): + record.args = {**args, "headers": _redact(headers)} + return True + + +def setup_logging() -> None: + """Apply ``TYPESAFE_LOG_LEVEL`` to the ``typesafe_sdk`` logger if it names a known level.""" + level = (os.environ.get(LOG_LEVEL_ENV) or "").strip().lower() + if level in LOG_LEVELS: + logger.setLevel(LOG_LEVELS[level]) + + +logger.addHandler(logging.NullHandler()) +logger.addFilter(SensitiveHeadersFilter()) +setup_logging() diff --git a/src/typesafe_sdk/_core/question_types.py b/src/typesafe_sdk/_core/question_types.py new file mode 100644 index 0000000..d0823b4 --- /dev/null +++ b/src/typesafe_sdk/_core/question_types.py @@ -0,0 +1,96 @@ +"""Question objects and raw input models.""" + +from collections.abc import Mapping +from typing import Literal, TypeAlias + +from typing_extensions import NotRequired, TypedDict + +from typesafe_sdk._core.json_types import JSONValue +from typesafe_sdk._schemas import models as wire + + +class NoulCriteria(TypedDict, total=False, extra_items=JSONValue | None): + """Optional descriptions of the yes and no outcomes.""" + + true: str | dict[str, JSONValue | None] | list[JSONValue | None] | None + """Description of the yes outcome as text, a JSON object, or an array; `None` leaves it undescribed.""" + false: str | dict[str, JSONValue | None] | list[JSONValue | None] | None + """Description of the no outcome as text, a JSON object, or an array; `None` leaves it undescribed.""" + + +class NoulModel(TypedDict, extra_items=JSONValue | None): + """A yes/no question dictionary with `type="noul"`, allowing extra JSON fields.""" + + type: Literal["noul"] + instructions: NotRequired[str | dict[str, JSONValue | None] | list[JSONValue | None] | None] + """The question to ask, expressed as text, a JSON object, or an array; optional.""" + criteria: NotRequired[NoulCriteria | None] + """Optional descriptions of the yes and no outcomes.""" + + +class ChoiceModel(TypedDict, extra_items=JSONValue | None): + """A choice question dictionary with `type="choice"`, allowing extra JSON fields.""" + + type: Literal["choice"] + instructions: NotRequired[str | dict[str, JSONValue | None] | list[JSONValue | None] | None] + """The question to ask, expressed as text, a JSON object, or an array; optional.""" + criteria: dict[str, str | dict[str, JSONValue | None] | list[JSONValue | None] | None] + """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels.""" + + +class ScoreModel(TypedDict, extra_items=JSONValue | None): + """A score question dictionary with `type="score"`, allowing extra JSON fields.""" + + type: Literal["score"] + instructions: NotRequired[str | dict[str, JSONValue | None] | list[JSONValue | None] | None] + """The question to ask, expressed as text, a JSON object, or an array; optional.""" + criteria: ( + list[str | dict[str, JSONValue | None] | list[JSONValue | None]] | dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]] + ) + """Nonempty text, object, or array descriptions indexed from zero, with no gaps in dictionary keys.""" + + +class Noul(wire.NoulQuestion, kw_only=True, omit_defaults=True): + """A yes/no question with optional descriptions for either outcome. + + See the [noul primitive](https://docs.typesafe.ai/primitives/noul) for details. + """ + + instructions: str | dict[str, JSONValue | None] | list[JSONValue | None] | None = None # pyrefly: ignore[bad-override-mutable-attribute] + """The question to ask, expressed as text, a JSON object, or an array; optional.""" + criteria: NoulCriteria | None = None # pyrefly: ignore[bad-override-mutable-attribute] + """Optional descriptions of the yes and no outcomes.""" + + +class Choice(wire.ChoiceQuestion, kw_only=True, omit_defaults=True): + """A question that selects between named alternatives. + + See the [choice primitive](https://docs.typesafe.ai/primitives/choice) for details. + """ + + criteria: dict[str, str | dict[str, JSONValue | None] | list[JSONValue | None] | None] + """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels.""" + instructions: str | dict[str, JSONValue | None] | list[JSONValue | None] | None = None # pyrefly: ignore[bad-override-mutable-attribute] + """The question to ask, expressed as text, a JSON object, or an array; optional.""" + + +class Score(wire.ScoreQuestion, kw_only=True, omit_defaults=True): + """A question that assigns a score using an ordered rubric. + + See the [score primitive](https://docs.typesafe.ai/primitives/score) for details. + """ + + criteria: ( # pyrefly: ignore[bad-override-mutable-attribute] + list[str | dict[str, JSONValue | None] | list[JSONValue | None]] | dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]] + ) + """Nonempty text, object, or array descriptions indexed from zero, with no gaps in dictionary keys.""" + instructions: str | dict[str, JSONValue | None] | list[JSONValue | None] | None = None # pyrefly: ignore[bad-override-mutable-attribute] + """The question to ask, expressed as text, a JSON object, or an array; optional.""" + + +QuestionModel: TypeAlias = NoulModel | ChoiceModel | ScoreModel +"""A question dictionary identified by its `type` key.""" +Question: TypeAlias = Noul | Choice | Score | QuestionModel +"""A question object or question dictionary.""" +Questions: TypeAlias = Mapping[str, Question] +"""Question inputs keyed by the names used to identify their answers.""" diff --git a/src/typesafe_sdk/_core/questions.py b/src/typesafe_sdk/_core/questions.py new file mode 100644 index 0000000..c71e3cf --- /dev/null +++ b/src/typesafe_sdk/_core/questions.py @@ -0,0 +1,49 @@ +"""Question normalization before JSON encoding.""" + +from collections.abc import Mapping +from typing import cast + +import msgspec + +from typesafe_sdk._core.errors import TypeSafeError +from typesafe_sdk._core.json_types import JSONValue +from typesafe_sdk._core.question_types import Choice, Noul, Question, Score, ScoreModel + + +def normalize_questions(questions: Mapping[str, Question]) -> dict[str, Question]: + if not questions: + raise TypeSafeError("At least one question is required.") + result: dict[str, Question] = {} + for name, question in questions.items(): + result[name] = question + if isinstance(question, Score): + criteria = _normalize_score_criteria(name, question.criteria) + if criteria is not question.criteria: + result[name] = msgspec.structs.replace(question, criteria=criteria) + elif not isinstance(question, (Noul, Choice)): + if not isinstance(question, dict) or not isinstance(question.get("type"), str) or not question["type"]: + raise TypeSafeError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".') + if question["type"] in ("choice", "score") and "criteria" not in question: + raise TypeSafeError(f'Question "{name}" requires "criteria".') + if question["type"] == "score": + criteria = _normalize_score_criteria(name, question["criteria"]) + if criteria is not question["criteria"]: + result[name] = cast(ScoreModel, {**question, "criteria": criteria}) + return result + + +def _normalize_score_criteria( + name: str, + criteria: list[str | dict[str, JSONValue | None] | list[JSONValue | None]] + | dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]], +) -> list[str | dict[str, JSONValue | None] | list[JSONValue | None]]: + """Validate a score question's criteria and normalize a keyed dict into a gap-free ordered list.""" + if not criteria: + raise TypeSafeError(f'Score question "{name}" has no criteria; at least one score is required.') + if isinstance(criteria, dict): + if any(type(key) is not int or key < 0 for key in criteria): + raise TypeSafeError(f'Score question "{name}" keys must be non-negative integers.') + if sorted(criteria) != list(range(len(criteria))): + raise TypeSafeError(f'Score question "{name}" defines scores {sorted(criteria)}, but scores must run from 0 with no gaps.') + return [criteria[index] for index in range(len(criteria))] + return criteria diff --git a/src/typesafe_sdk/_core/response_types.py b/src/typesafe_sdk/_core/response_types.py new file mode 100644 index 0000000..cfbd634 --- /dev/null +++ b/src/typesafe_sdk/_core/response_types.py @@ -0,0 +1,181 @@ +"""Ergonomic answer objects and response metadata, built on the generated wire schemas. + +The wire structs in `typesafe_sdk._schemas.models` mirror the OpenAPI schema. The public answer types +subclass them, adding immutability and integer-keyed score maps, and let msgspec decode straight into +the public types — no wrapping layer. Because the wire answer structs are tagged, the public `Answer` +union decodes in one call; per-answer dispatch is kept only to skip answer kinds a future API adds. +""" + +from functools import cached_property +from typing import Any, TypeAlias + +import httpx2 +import msgspec +from msgspec import field +from typing_extensions import Self, override + +from typesafe_sdk._core.logging import logger +from typesafe_sdk._core.schemas.base import Response, field_path, validation_error +from typesafe_sdk._schemas import models as wire +from typesafe_sdk._schemas.models import ModelMetadata + + +class NoulAnswer(wire.NoulAnswer, kw_only=True): + """A yes/no answer.""" + + noul: float + """Probability of a yes answer, from zero to one.""" + + +class ChoiceAnswer(wire.ChoiceAnswer, kw_only=True): + """A selected label and its probabilities.""" + + choice: str + """The selected label.""" + confidence: float + """Reported confidence in the selected label.""" + probabilities: dict[str, float] + """Probabilities keyed by label.""" + + +class ScoreAnswer(wire.ScoreAnswer, kw_only=True): + """An expected score with its rubric and probabilities.""" + + score: float + """Expected score, which may fall between the integer rubric levels.""" + confidence: float + """Reported confidence in the score.""" + # JSON object keys are strings; `dict[int, ...]` tells msgspec to coerce them to the integer score + # levels at decode time. `Any` (not the recursive `JSONValue`) keeps the nested values decodable. + legend: dict[int, str | dict[str, Any] | list[Any]] # pyrefly: ignore[bad-override-mutable-attribute] + """Rubric descriptions keyed by integer score.""" + probabilities: dict[int, float] # pyrefly: ignore[bad-override-mutable-attribute] + """Probabilities keyed by integer score.""" + + +Answer: TypeAlias = NoulAnswer | ChoiceAnswer | ScoreAnswer +"""An answer to a single question, identified by its `type`.""" + + +# The OpenAPI Usage schema still requires billing_units, which the API does not return. +class Usage(msgspec.Struct, kw_only=True): + """Token counts for a request, when reported by the API.""" + + input_tokens: int | None = None + """Number of input tokens used, or `None` when the API did not report it.""" + output_tokens: int | None = None + """Number of output tokens used, or `None` when the API did not report it.""" + + +class _AnswerTag(msgspec.Struct): + """Reads only an answer's discriminator so it can be routed to the right struct.""" + + type: str + + +class _SystemOneBody(msgspec.Struct, kw_only=True): + """Fast-path decode target: the whole tagged response, straight into the public types.""" + + model: str + usage: Usage + answers: dict[str, Answer] + + +class _SystemOneRawBody(msgspec.Struct, kw_only=True): + """Dispatch decode target; answers stay raw so unknown tags can be skipped one by one.""" + + model: str + usage: Usage + answers: dict[str, msgspec.Raw] + + +_ANSWER_TYPES: dict[str, type[Answer]] = { + "noul": NoulAnswer, + "choice": ChoiceAnswer, + "score": ScoreAnswer, +} + +# One-shot decoder for the whole tagged response. The `Answer` structs carry a `tag_field`, so the +# public union decodes in a single call; per-answer dispatch (below) handles only forward-compat +# unknown types and precise error paths. +_RESPONSE_DECODER = msgspec.json.Decoder(_SystemOneBody) + + +class SystemOneResponse(Response, frozen=True, kw_only=True): + """Answers grouped by question type with model and usage metadata.""" + + model: str + """The model used to answer the request.""" + usage: Usage + """Token usage for the request.""" + answers: dict[str, Answer] = field(default_factory=dict) + """All answer objects keyed by question name.""" + + @classmethod + @override + def _decode(cls, response: httpx2.Response) -> Self: + native = cls._decode_native(response) + return native if native is not None else cls._decode_by_dispatch(response) + + @classmethod + def _decode_native(cls, response: httpx2.Response) -> Self | None: + """Fast path: decode the whole tagged response in one msgspec call. + + Returns `None` — deferring to per-answer dispatch — when an answer type is unknown or a field + is malformed. Dispatch then skips unknown types (the common forward-compat case) or re-raises + with a precise field path. + """ + try: + body = _RESPONSE_DECODER.decode(response.content) + except msgspec.ValidationError: + return None + return cls(model=body.model, usage=body.usage, answers=body.answers) + + @classmethod + def _decode_by_dispatch(cls, response: httpx2.Response) -> Self: + body = msgspec.json.decode(response.content, type=_SystemOneRawBody) + answers: dict[str, Answer] = {} + for name, raw in body.answers.items(): + try: + tag = msgspec.json.decode(raw, type=_AnswerTag).type + except msgspec.ValidationError as error: + raise validation_error(response, f"answers.{name}.type") from error + answer_type = _ANSWER_TYPES.get(tag) + if answer_type is None: + # Forward-compat: ignore answer types this SDK version does not model. The raw payload + # is still available through `response.raw_http_response`. + logger.warning("Ignoring answer %r with unrecognized type %r", name, tag) + continue + try: + answers[name] = msgspec.json.decode(raw, type=answer_type) + except msgspec.ValidationError as error: + raise validation_error(response, field_path(("answers", name), error)) from error + return cls(model=body.model, usage=body.usage, answers=answers) + + @cached_property + def nouls(self) -> dict[str, NoulAnswer]: + """Yes/no answers keyed by question name.""" + return {name: answer for name, answer in self.answers.items() if isinstance(answer, NoulAnswer)} + + @cached_property + def choices(self) -> dict[str, ChoiceAnswer]: + """Choice answers keyed by question name.""" + return {name: answer for name, answer in self.answers.items() if isinstance(answer, ChoiceAnswer)} + + @cached_property + def scores(self) -> dict[str, ScoreAnswer]: + """Score answers keyed by question name.""" + return {name: answer for name, answer in self.answers.items() if isinstance(answer, ScoreAnswer)} + + +class ListModelsResponse(Response, frozen=True, kw_only=True): + """The models available to the account.""" + + models: tuple[ModelMetadata, ...] + """The available models.""" + + @classmethod + @override + def _decode(cls, response: httpx2.Response) -> Self: + body = msgspec.json.decode(response.content, type=wire.ModelMetadataList) + return cls(models=tuple(body.models)) diff --git a/src/typesafe_sdk/_core/retry.py b/src/typesafe_sdk/_core/retry.py new file mode 100644 index 0000000..aa627ce --- /dev/null +++ b/src/typesafe_sdk/_core/retry.py @@ -0,0 +1,126 @@ +"""RetryPolicy configuration and the Tenacity policies built from it.""" + +import math +import random +from collections.abc import Callable +from dataclasses import dataclass, field + +from tenacity import AsyncRetrying, RetryCallState, Retrying, retry_if_exception, stop_after_attempt, stop_before_delay +from tenacity.stop import stop_base + +from typesafe_sdk._core.config import resolve_timeout +from typesafe_sdk._core.errors import TypeSafeAPIConnectionError, TypeSafeAPIError, TypeSafeAPITimeoutError, parse_retry_after + +# `build_tenacity`/`build_tenacity_async` are internal build seams and stay out of the public docs. +__all__ = ["RetryPolicy"] + + +def _retry_after(state: RetryCallState) -> float | None: + error = state.outcome.exception() if state.outcome is not None else None + if isinstance(error, TypeSafeAPIError): + delay = parse_retry_after(error.headers) + if delay is not None: + return delay / 1000 + return None + + +def _backoff(attempt: int, initial: float, maximum: float, jitter: float) -> float: + exponent = min(attempt - 1, math.ceil(math.log2(maximum / initial))) + exponential = min(initial * 2**exponent, maximum) + return math.floor(exponential * (1 - random.random() * jitter) * 1000 + 0.5) / 1000 # noqa: S311 - Backoff jitter, not cryptography. + + +@dataclass(frozen=True) +class RetryPolicy: + """Configuration for SDK retry behavior. + + Examples: + ```python + from typesafe_sdk import RetryPolicy, TypeSafeClient + + client = TypeSafeClient( + retry=RetryPolicy( + max_retries=3, timeout=10.0, http_statuses={429, 500, 502, 503, 504} + ) + ) + ``` + """ + + max_retries: int = 2 + """Maximum retries after the initial attempt; `0` disables retries.""" + + backoff_initial: float = 0.5 + """First backoff delay in seconds, doubled each attempt up to `backoff_max`.""" + + backoff_max: float = 5.0 + """Maximum backoff delay in seconds.""" + + backoff_jitter: float = 0.25 + """Fraction of each backoff delay randomly subtracted, between 0 and 1.""" + + http_statuses: set[int] = field(default_factory=lambda: {408, 429, *range(500, 600)}) + """HTTP status codes that are retried.""" + + respect_retry_after: bool = True + """Whether to honor `Retry-After` and `retry-after-ms` response headers.""" + + api_connection_error: bool = True + """Whether to retry `TypeSafeAPIConnectionError`, raised when the request cannot reach or read from the server.""" + + api_timeout_error: bool = True + """Whether to retry `TypeSafeAPITimeoutError`, raised when the request exceeds its timeout.""" + + exceptions: set[type[BaseException]] = field(default_factory=set) + """Additional exception types that trigger a retry, on top of the built-in rules.""" + + predicate: Callable[[BaseException], bool] | None = None + """An optional predicate called with the raised exception; returning `True` triggers a retry in addition to the other rules.""" + + timeout: float | None = 30.0 + """Total retry budget in seconds per SDK call, including the initial attempt and delays; `None` disables the limit. + + Stops before a retry whose delay would reach or exceed the budget, re-raising the last error. + """ + + def __post_init__(self) -> None: + """Validate the optional retry timeout.""" + if self.timeout is not None: + resolve_timeout(self.timeout) + + def _retryable(self, error: BaseException) -> bool: + if isinstance(error, TypeSafeAPITimeoutError): + builtin = self.api_timeout_error + elif isinstance(error, TypeSafeAPIConnectionError): + builtin = self.api_connection_error + elif isinstance(error, TypeSafeAPIError): + builtin = error.status in self.http_statuses + else: + builtin = False + return builtin or isinstance(error, tuple(self.exceptions)) or (self.predicate is not None and self.predicate(error)) + + def _wait(self, state: RetryCallState) -> float: + if self.respect_retry_after: + delay = _retry_after(state) + if delay is not None: + return delay + return _backoff(state.attempt_number, self.backoff_initial, self.backoff_max, self.backoff_jitter) + + def _stop(self) -> stop_base: + attempts = stop_after_attempt(self.max_retries + 1) + return attempts if self.timeout is None else attempts | stop_before_delay(self.timeout) + + def _build_tenacity(self) -> Retrying: + return Retrying(stop=self._stop(), wait=self._wait, retry=retry_if_exception(self._retryable), reraise=True) + + def _build_tenacity_async(self) -> AsyncRetrying: + return AsyncRetrying(stop=self._stop(), wait=self._wait, retry=retry_if_exception(self._retryable), reraise=True) + + +def build_tenacity(policy: "RetryPolicy | None") -> Retrying: + """Build the synchronous Tenacity policy, falling back to default retry behavior when `policy` is None.""" + return (policy or RetryPolicy())._build_tenacity() # noqa: SLF001 - internal build seam. + + +def build_tenacity_async(policy: "RetryPolicy | None") -> AsyncRetrying: + """Build the asynchronous Tenacity policy, falling back to default retry behavior when `policy` is None.""" + return (policy or RetryPolicy())._build_tenacity_async() # noqa: SLF001 - internal build seam. diff --git a/src/typesafe_sdk/_core/schemas/__init__.py b/src/typesafe_sdk/_core/schemas/__init__.py new file mode 100644 index 0000000..6f7c180 --- /dev/null +++ b/src/typesafe_sdk/_core/schemas/__init__.py @@ -0,0 +1 @@ +"""msgspec structs for API responses.""" diff --git a/src/typesafe_sdk/_core/schemas/base.py b/src/typesafe_sdk/_core/schemas/base.py new file mode 100644 index 0000000..a2b1252 --- /dev/null +++ b/src/typesafe_sdk/_core/schemas/base.py @@ -0,0 +1,84 @@ +"""Base msgspec structs shared by the response schemas.""" + +import re +from functools import cached_property + +import httpx2 +import msgspec +from msgspec.structs import force_setattr +from typing_extensions import Self + +from typesafe_sdk._core.constants import REQUEST_ID_HEADER +from typesafe_sdk._core.errors import TypeSafeAPIResponseValidationError, TypeSafeError, api_error +from typesafe_sdk._core.json import deserialize + + +# Unknown fields in a response are ignored rather than rejected, so a newer server never breaks +# an older client. +class Schema(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=False): + """Base type for response objects. Instances are immutable.""" + + +_ERROR_AT = re.compile(r"`\$(?P[^`]*)`") +_ERROR_MISSING = re.compile(r"missing required field `(?P[^`]+)`") + + +def field_path(prefix: tuple[str, ...], error: msgspec.DecodeError) -> str: + """Translate a msgspec decode error's location into the SDK's dotted `field_path`.""" + message = str(error) + segments: list[str] = [] + at = _ERROR_AT.search(message) + if at is not None: + segments = [segment for segment in at.group("path").split(".") if segment] + elif (missing := _ERROR_MISSING.search(message)) is not None: + segments = [missing.group("field")] + return ".".join((*prefix, *segments)) + + +def validation_error(response: httpx2.Response, path: str) -> TypeSafeAPIResponseValidationError: + """Build a `TypeSafeAPIResponseValidationError` locating a bad field in `response`.""" + return TypeSafeAPIResponseValidationError(response.status_code, deserialize(response.content), response.headers, path) + + +# dict=True gives the struct a __dict__ so subclasses can memoize derived views with cached_property. +class Response(Schema, frozen=True, kw_only=True, dict=True): + """A response object that also exposes the originating HTTP response via ``raw_http_response`` and ``request_id``.""" + + _request_id: str | None = None + _raw: httpx2.Response | None = None + + @classmethod + def from_http_response(cls, response: httpx2.Response) -> Self: + """Parse an HTTP response into this response type, attaching the raw response. + + A non-success status raises the matching `TypeSafeAPIError`; a body that does not + match the schema raises a `TypeSafeAPIResponseValidationError`. + """ + if not response.is_success: + raise api_error(response.status_code, deserialize(response.content), response.headers) + try: + result = cls._decode(response) + except msgspec.DecodeError as error: + raise validation_error(response, field_path((), error)) from error + force_setattr(result, "_request_id", response.headers.get(REQUEST_ID_HEADER)) + force_setattr(result, "_raw", response) + return result + + @classmethod + def _decode(cls, response: httpx2.Response) -> Self: + """Build the response from its HTTP body. Subclasses implement the type-specific decode.""" + raise NotImplementedError + + @cached_property + def request_id(self) -> str: + """The ``x-typesafe-request-id`` response header.""" + if self._request_id is None: + raise TypeSafeError("The response did not include a request ID.") + return self._request_id + + @property + def raw_http_response(self) -> httpx2.Response: + """The underlying `httpx2.Response`, exposing status, headers, and body.""" + if self._raw is None: + raise TypeSafeError("The response was not created from a raw HTTP response.") + return self._raw diff --git a/src/typesafe_sdk/_core/transport.py b/src/typesafe_sdk/_core/transport.py new file mode 100644 index 0000000..a585447 --- /dev/null +++ b/src/typesafe_sdk/_core/transport.py @@ -0,0 +1,169 @@ +"""Shared HTTP request preparation, logging, and dispatch to response types.""" + +import logging +import platform +import sys +import time +from collections.abc import Generator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Generic, TypeVar + +import httpx2 +import msgspec +from tenacity import AsyncRetrying, Retrying + +from typesafe_sdk._core.config import Config, resolve_timeout +from typesafe_sdk._core.constants import ( + ACCEPT_HEADER, + AUTHORIZATION_HEADER, + CONTENT_TYPE_HEADER, + JSON_CONTENT_TYPE, + REQUEST_ID_HEADER, + RETRY_COUNT_HEADER, + RUNTIME_HEADER, + SDK_HEADER, + SDK_NAME, + USER_AGENT_HEADER, +) +from typesafe_sdk._core.errors import TypeSafeAPIConnectionError, TypeSafeAPITimeoutError, TypeSafeError +from typesafe_sdk._core.json import serialize +from typesafe_sdk._core.logging import logger +from typesafe_sdk._core.retry import RetryPolicy, build_tenacity, build_tenacity_async +from typesafe_sdk._core.schemas.base import Response +from typesafe_sdk._version import __version__ + +RUNTIME = f"python/{platform.python_version()} ({sys.platform}; {platform.machine()})" +ResponseT = TypeVar("ResponseT", bound=Response) + + +@dataclass(frozen=True, repr=False) +class Request(Generic[ResponseT]): + """An immutable description of a single HTTP request to send.""" + + method: str + url: str + headers: httpx2.Headers + content: bytes | None + timeout: float | httpx2.Timeout + response_type: type[ResponseT] + + +class RequestState(Generic[ResponseT]): + """Per-send mutable state: tracks attempt count and timing across retries of one `Request`.""" + + def __init__(self, request: Request[ResponseT]) -> None: + self._request = request + self.attempts = 0 + self.started = 0.0 + + def _log_wire(self, arrow: str, headers: httpx2.Headers, body: bytes | None) -> None: + # Headers go through `args["headers"]` so SensitiveHeadersFilter redacts them. + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "%(method)s %(url)s %(arrow)s headers=%(headers)s body=%(body)r", + {"method": self._request.method, "url": self._request.url, "arrow": arrow, "headers": dict(headers), "body": body}, + ) + + @contextmanager + def attempt(self) -> Generator[httpx2.Headers, None, None]: + request = self._request + headers = httpx2.Headers(request.headers) + if self.attempts: + headers[RETRY_COUNT_HEADER] = str(self.attempts) + logger.info("%s %s retry %s", request.method, request.url, self.attempts) + self.attempts += 1 + self.started = time.monotonic() + self._log_wire("->", headers, request.content) + try: + yield headers + except httpx2.RequestError as error: + logger.info("%s %s <- %s", request.method, request.url, type(error).__name__) + if isinstance(error, httpx2.TimeoutException): + raise TypeSafeAPITimeoutError(request.timeout) from error + raise TypeSafeAPIConnectionError(f"Connection error: {error}") from error + + def parse(self, response: httpx2.Response) -> ResponseT: + request = self._request + logger.info( + "%s %s <- %s in %.0fms (request %s)", + request.method, + request.url, + response.status_code, + (time.monotonic() - self.started) * 1000, + response.headers.get(REQUEST_ID_HEADER, "-"), + ) + self._log_wire("<-", response.headers, response.content) + return request.response_type.from_http_response(response) + + +def prepare( + config: Config, + method: str, + path: str, + body: object, + timeout: float | httpx2.Timeout | None, + headers: Mapping[str, str] | None, + response_type: type[ResponseT], +) -> Request[ResponseT]: + merged = httpx2.Headers(config.default_headers) + merged.update(headers or {}) + merged.pop(RETRY_COUNT_HEADER, None) + merged.update( + { + AUTHORIZATION_HEADER: f"Bearer {config.api_key}", + ACCEPT_HEADER: JSON_CONTENT_TYPE, + USER_AGENT_HEADER: f"{SDK_NAME}/{__version__}", + SDK_HEADER: f"{SDK_NAME}/{__version__}", + RUNTIME_HEADER: RUNTIME, + } + ) + content: bytes | None = None + if body is not None: + content = _encode_body(body) + merged[CONTENT_TYPE_HEADER] = JSON_CONTENT_TYPE + return Request( + method, + config.base_url + path, + merged, + content, + resolve_timeout(config.timeout if timeout is None else timeout), + response_type, + ) + + +def _encode_body(body: object) -> bytes: + try: + return serialize(body) + except (msgspec.EncodeError, TypeError) as error: + raise TypeSafeError("The request body could not be encoded as JSON") from error + + +def send(http_client: httpx2.Client, retry: Retrying, request: Request[ResponseT], override: RetryPolicy | None = None) -> ResponseT: + state = RequestState(request) + + def attempt() -> ResponseT: + with state.attempt() as headers: + response = http_client.request( + request.method, request.url, content=request.content, headers=headers, timeout=request.timeout, auth=None + ) + return state.parse(response) + + policy = retry if override is None else build_tenacity(override) + return policy.copy()(attempt) + + +async def send_async( + http_client: httpx2.AsyncClient, retry: AsyncRetrying, request: Request[ResponseT], override: RetryPolicy | None = None +) -> ResponseT: + state = RequestState(request) + + async def attempt() -> ResponseT: + with state.attempt() as headers: + response = await http_client.request( + request.method, request.url, content=request.content, headers=headers, timeout=request.timeout, auth=None + ) + return state.parse(response) + + policy = retry if override is None else build_tenacity_async(override) + return await policy.copy()(attempt) diff --git a/src/typesafe_sdk/_schemas/__init__.py b/src/typesafe_sdk/_schemas/__init__.py new file mode 100644 index 0000000..72d9a90 --- /dev/null +++ b/src/typesafe_sdk/_schemas/__init__.py @@ -0,0 +1,6 @@ +"""Wire schemas generated from the API's OpenAPI spec. + +`models.py` is generated by `just codegen` (datamodel-code-generator, msgspec output) and must not +be edited by hand. The structs model types and JSON parsing only; they carry no validation +constraints, since the API's own output is trusted. +""" diff --git a/src/typesafe_sdk/_schemas/models.py b/src/typesafe_sdk/_schemas/models.py new file mode 100644 index 0000000..4b9ca8e --- /dev/null +++ b/src/typesafe_sdk/_schemas/models.py @@ -0,0 +1,89 @@ +# generated by datamodel-codegen: +# filename: https://api.typesafe.ai/openapi.json + +from __future__ import annotations +from typing import Any, TypeAlias +from msgspec import Struct, UNSET, UnsetType + + +class ChoiceAnswer(Struct, tag_field="type", tag="choice"): + choice: str + confidence: float + probabilities: dict[str, float] + + +class ChoiceQuestion(Struct, tag_field="type", tag="choice"): + criteria: dict[str, str | dict[str, Any] | list[Any] | None] + instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET + + +class ModelMetadata(Struct): + name: str + description: str + release_date: str + + +class ModelMetadataList(Struct): + models: list[ModelMetadata] + + +class NoulAnswer(Struct, tag_field="type", tag="noul"): + noul: float + + +class NoulCriteria(Struct): + true: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET + false: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET + + +class NoulQuestion(Struct, tag_field="type", tag="noul"): + instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET + criteria: NoulCriteria | None | UnsetType = UNSET + + +class ScoreAnswer(Struct, tag_field="type", tag="score"): + score: float + confidence: float + legend: dict[str, str | dict[str, Any] | list[Any]] + probabilities: dict[str, float] + + +class ScoreQuestion(Struct, tag_field="type", tag="score"): + criteria: list[str | dict[str, Any] | list[Any]] + instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET + + +class Usage(Struct): + billing_units: int + input_tokens: int | None | UnsetType = UNSET + output_tokens: int | None | UnsetType = UNSET + + +class ValidationError(Struct): + loc: list[str | int] + msg: str + type: str + input: Any | UnsetType = UNSET + ctx: dict[str, Any] | UnsetType = UNSET + + +Answer: TypeAlias = NoulAnswer | ScoreAnswer | ChoiceAnswer + + +class HTTPValidationError(Struct): + detail: list[ValidationError] | UnsetType = UNSET + + +Question: TypeAlias = NoulQuestion | ChoiceQuestion | ScoreQuestion + + +class SystemOneRequest(Struct): + state: str | dict[str, Any] | list[Any] + model: str + questions: dict[str, Question] + + +class SystemOneResponse(Struct): + model: str + answers: dict[str, Answer] + usage: Usage diff --git a/src/typesafe_sdk/_version.py b/src/typesafe_sdk/_version.py new file mode 100644 index 0000000..9208ac7 --- /dev/null +++ b/src/typesafe_sdk/_version.py @@ -0,0 +1,5 @@ +"""Installed SDK version.""" + +from importlib.metadata import version + +__version__ = version("typesafe-sdk") diff --git a/src/typesafe_sdk/constants.py b/src/typesafe_sdk/constants.py new file mode 100644 index 0000000..9343ef7 --- /dev/null +++ b/src/typesafe_sdk/constants.py @@ -0,0 +1,22 @@ +"""Public environment-variable names and client defaults.""" + +API_KEY_ENV = "TYPESAFE_API_KEY" +"""Environment variable for the API key.""" + +BASE_URL_ENV = "TYPESAFE_BASE_URL" +"""Environment variable for the API base URL.""" + +DEFAULT_MODEL_ENV = "TYPESAFE_DEFAULT_MODEL" +"""Environment variable for the default model.""" + +LOG_LEVEL_ENV = "TYPESAFE_LOG_LEVEL" +"""Environment variable for the logging level.""" + +DEFAULT_BASE_URL = "https://api.typesafe.ai" +"""Default API base URL.""" + +DEFAULT_MODEL = "jev-latest" +"""Default model name.""" + +DEFAULT_TIMEOUT = 10.0 +"""Default timeout in seconds for each HTTP operation.""" diff --git a/src/typesafe_sdk/py.typed b/src/typesafe_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__snapshots__/test_public_api_surface.ambr b/tests/__snapshots__/test_public_api_surface.ambr new file mode 100644 index 0000000..8c2739d --- /dev/null +++ b/tests/__snapshots__/test_public_api_surface.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_constructor_kwargs[AsyncTypeSafeClient] + list([ + 'api_key', + 'model', + 'retry', + 'timeout', + 'headers', + 'transport', + 'http_client', + 'base_url', + ]) +# --- +# name: test_constructor_kwargs[TypeSafeClient] + list([ + 'api_key', + 'model', + 'retry', + 'timeout', + 'headers', + 'transport', + 'http_client', + 'base_url', + ]) +# --- +# name: test_public_members[typesafe_sdk.constants] + list([ + 'API_KEY_ENV', + 'BASE_URL_ENV', + 'DEFAULT_BASE_URL', + 'DEFAULT_MODEL', + 'DEFAULT_MODEL_ENV', + 'DEFAULT_TIMEOUT', + 'LOG_LEVEL_ENV', + ]) +# --- +# name: test_public_members[typesafe_sdk] + list([ + 'Answer', + 'AsyncModels', + 'AsyncTypeSafeClient', + 'Choice', + 'ChoiceAnswer', + 'ChoiceModel', + 'JSONValue', + 'ListModelsResponse', + 'ModelMetadata', + 'Models', + 'Noul', + 'NoulAnswer', + 'NoulCriteria', + 'NoulModel', + 'Question', + 'QuestionModel', + 'Questions', + 'RetryPolicy', + 'Score', + 'ScoreAnswer', + 'ScoreModel', + 'SystemOneResponse', + 'TypeSafeAPIConnectionError', + 'TypeSafeAPIError', + 'TypeSafeAPIResponseValidationError', + 'TypeSafeAPITimeoutError', + 'TypeSafeAuthenticationError', + 'TypeSafeBadRequestError', + 'TypeSafeClient', + 'TypeSafeError', + 'TypeSafeInternalServerError', + 'TypeSafeNotFoundError', + 'TypeSafePermissionDeniedError', + 'TypeSafeRateLimitError', + 'TypeSafeUnprocessableEntityError', + 'Usage', + 'constants', + ]) +# --- diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9709d21 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,100 @@ +import os +from collections.abc import AsyncIterator, Callable, Mapping + +import httpx2 +import pytest + +from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy, TypeSafeClient +from typesafe_sdk.constants import API_KEY_ENV, BASE_URL_ENV, DEFAULT_MODEL_ENV, LOG_LEVEL_ENV + +Client = TypeSafeClient | AsyncTypeSafeClient + + +class ClientFactory: + def __init__(self, async_mode: bool) -> None: + self.async_mode = async_mode + self.instances: list[Client] = [] + + def __call__( + self, + handler: Callable[[httpx2.Request], httpx2.Response], + *, + retries: bool = False, + api_key: str | None = "test-key", + model: str | None = None, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + headers: Mapping[str, str] | None = None, + transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None, + http_client: httpx2.Client | httpx2.AsyncClient | None = None, + base_url: str | None = None, + ) -> Client: + if transport is None and http_client is None: + transport = httpx2.MockTransport(handler) + if retry is None and not retries: + retry = RetryPolicy(max_retries=0) + client: Client + if self.async_mode: + assert transport is None or isinstance(transport, httpx2.AsyncBaseTransport) + assert http_client is None or isinstance(http_client, httpx2.AsyncClient) + client = AsyncTypeSafeClient( + api_key=api_key, + model=model, + retry=retry, + timeout=timeout, + headers=headers, + transport=transport, + http_client=http_client, + base_url=base_url, + ) + else: + assert transport is None or isinstance(transport, httpx2.BaseTransport) + assert http_client is None or isinstance(http_client, httpx2.Client) + client = TypeSafeClient( + api_key=api_key, + model=model, + retry=retry, + timeout=timeout, + headers=headers, + transport=transport, + http_client=http_client, + base_url=base_url, + ) + self.instances.append(client) + return client + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +async def clients(request: pytest.FixtureRequest) -> AsyncIterator[ClientFactory]: + factory = ClientFactory(request.param) + yield factory + for client in factory.instances: + if isinstance(client, AsyncTypeSafeClient): + await client.aclose() + else: + client.close() + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + if request.node.get_closest_marker("integration") is None: + for name in (API_KEY_ENV, BASE_URL_ENV, DEFAULT_MODEL_ENV, LOG_LEVEL_ENV): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def live_api_key() -> str: + key = os.environ.get(API_KEY_ENV, "").strip() + if not key: + pytest.skip(f"{API_KEY_ENV} is not set") + return key + + +@pytest.fixture(params=["sync", "async"]) +async def live_client(request: pytest.FixtureRequest, live_api_key: str) -> AsyncIterator[Client]: + if request.param == "async": + async with AsyncTypeSafeClient(api_key=live_api_key, timeout=120) as client: + yield client + else: + with TypeSafeClient(api_key=live_api_key, timeout=120) as client: + yield client diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..af1ff7d --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,53 @@ +from collections.abc import Mapping + +import httpx2 +from typing_extensions import override + +from typesafe_sdk import AsyncTypeSafeClient, JSONValue, ModelMetadata, Questions, RetryPolicy, SystemOneResponse, TypeSafeClient + + +class TrackingTransport(httpx2.MockTransport): + close_calls = 0 + aclose_calls = 0 + + @override + def close(self) -> None: + self.close_calls += 1 + + @override + async def aclose(self) -> None: + self.aclose_calls += 1 + + +async def models( + client: TypeSafeClient | AsyncTypeSafeClient, + *, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + extra_headers: Mapping[str, str] | None = None, +) -> tuple[ModelMetadata, ...]: + if isinstance(client, AsyncTypeSafeClient): + return (await client.models.list(retry=retry, timeout=timeout, extra_headers=extra_headers)).models + else: + return client.models.list(retry=retry, timeout=timeout, extra_headers=extra_headers).models + + +async def system_one( + client: TypeSafeClient | AsyncTypeSafeClient, + *, + state: str | dict[str, JSONValue | None] | list[JSONValue | None], + questions: Questions, + model: str | None = None, + extra_body: Mapping[str, JSONValue | None] | None = None, + retry: RetryPolicy | None = None, + timeout: float | httpx2.Timeout | None = None, + extra_headers: Mapping[str, str] | None = None, +) -> SystemOneResponse: + if isinstance(client, AsyncTypeSafeClient): + return await client.system_one( + state, questions, model=model, extra_body=extra_body, retry=retry, timeout=timeout, extra_headers=extra_headers + ) + else: + return client.system_one( + state, questions, model=model, extra_body=extra_body, retry=retry, timeout=timeout, extra_headers=extra_headers + ) diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..fff1a4a --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,597 @@ +import asyncio +import logging +from importlib.metadata import version +from typing import Any, cast + +import httpx2 +import msgspec +import pytest +from typing_extensions import assert_type + +from tests.conftest import ClientFactory +from tests.helpers import TrackingTransport, models, system_one +from typesafe_sdk import ( + AsyncTypeSafeClient, + Choice, + ChoiceAnswer, + JSONValue, + ModelMetadata, + Noul, + NoulAnswer, + QuestionModel, + Questions, + Score, + ScoreAnswer, + TypeSafeAPIConnectionError, + TypeSafeAPIError, + TypeSafeAPIResponseValidationError, + TypeSafeAPITimeoutError, + TypeSafeAuthenticationError, + TypeSafeBadRequestError, + TypeSafeClient, + TypeSafeError, + TypeSafeInternalServerError, + TypeSafeNotFoundError, + TypeSafePermissionDeniedError, + TypeSafeRateLimitError, + TypeSafeUnprocessableEntityError, + Usage, +) + +RESULT = { + "model": "jev-latest", + "usage": {"input_tokens": 12, "output_tokens": 3}, + "answers": { + "spam": {"type": "noul", "noul": 0.98}, + "tone": {"type": "choice", "choice": "friendly", "confidence": 0.9, "probabilities": {"friendly": 0.9, "hostile": 0.1}}, + "quality": { + "type": "score", + "score": 1.7, + "confidence": 0.8, + "legend": {"0": "bad", "1": "ok", "2": "great"}, + "probabilities": {"0": 0.1, "1": 0.1, "2": 0.8}, + }, + }, +} +CARD = {"name": "jev-latest", "description": "Fast model", "release_date": "2026-08-01"} + + +@pytest.mark.parametrize("score_map", [False, True]) +@pytest.mark.parametrize("question_form", ["dataclass", "raw", "mixed"]) +async def test_round_trip(clients: ClientFactory, score_map: bool, question_form: str) -> None: + criteria: ( + dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]] | list[str | dict[str, JSONValue | None] | list[JSONValue | None]] + ) + if score_map: + criteria = {2: "great", 0: "bad", 1: "ok"} + else: + criteria = ["bad", "ok", "great"] + raw: dict[str, QuestionModel] = { + "spam": {"type": "noul", "instructions": "Spam?"}, + "tone": {"type": "choice", "instructions": "Tone?", "criteria": {"friendly": None, "hostile": None}}, + "quality": {"type": "score", "instructions": "Quality?", "criteria": criteria}, + } + questions: Questions + if question_form == "raw": + questions = raw + else: + questions = { + "spam": Noul(instructions="Spam?") if question_form == "dataclass" else raw["spam"], + "tone": Choice(instructions="Tone?", criteria={"friendly": None, "hostile": None}), + "quality": Score(instructions="Quality?", criteria=criteria), + } + expected: dict[str, JSONValue | None] = { + "state": {"document": "Hello 🌍"}, + "model": "jev-latest", + "questions": { + "spam": {"type": "noul", "instructions": "Spam?"}, + "tone": {"type": "choice", "instructions": "Tone?", "criteria": {"friendly": None, "hostile": None}}, + "quality": {"type": "score", "instructions": "Quality?", "criteria": ["bad", "ok", "great"]}, + }, + } + + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "POST" + assert str(request.url) == "https://api.typesafe.ai/v1/systemone" + assert msgspec.json.decode(request.content) == expected + assert request.headers["content-type"] == "application/json" + return httpx2.Response(200, content=msgspec.json.encode(RESULT)) + + result = await system_one( + clients(handler), + state={"document": "Hello 🌍"}, + questions=questions, + ) + assert "nouls" not in result.__dict__ + assert "choices" not in result.__dict__ + assert "scores" not in result.__dict__ + assert_type(result.nouls, dict[str, NoulAnswer]) + assert_type(result.choices, dict[str, ChoiceAnswer]) + assert_type(result.scores, dict[str, ScoreAnswer]) + assert result.nouls is result.nouls + assert result.choices is result.choices + assert result.scores is result.scores + assert result.model == "jev-latest" + assert result.usage == Usage(input_tokens=12, output_tokens=3) + assert set(result.nouls) == {"spam"} + assert set(result.choices) == {"tone"} + assert set(result.scores) == {"quality"} + assert result.nouls["spam"].noul == 0.98 + assert result.choices["tone"].choice == "friendly" + assert result.choices["tone"].confidence == 0.9 + assert result.choices["tone"].probabilities == {"friendly": 0.9, "hostile": 0.1} + assert result.scores["quality"].score == 1.7 + assert result.scores["quality"].confidence == 0.8 + assert result.scores["quality"].legend == {0: "bad", 1: "ok", 2: "great"} + assert result.scores["quality"].probabilities == {0: 0.1, 1: 0.1, 2: 0.8} + assert result.answers["quality"] is result.scores["quality"] + assert result.answers["spam"] is result.nouls["spam"] + assert result.answers["tone"] is result.choices["tone"] + assert set(result.answers) == {"spam", "tone", "quality"} + with pytest.raises(AttributeError): + cast(Any, result).model = "other" + + +async def test_extra_body_shallow_override(clients: ClientFactory) -> None: + expected = { + "state": "hi", + "model": "override-model", + "questions": {"q": {"type": "noul", "instructions": "?"}}, + "beam_width": 4, + "nullable": None, + } + + def handler(request: httpx2.Request) -> httpx2.Response: + assert msgspec.json.decode(request.content) == expected + return httpx2.Response(200, content=msgspec.json.encode(RESULT)) + + await system_one( + clients(handler), + state="hi", + questions={"q": {"type": "noul", "instructions": "?"}}, + model="call-model", + extra_body={"model": "override-model", "beam_width": 4, "nullable": None}, + ) + + +async def test_unserializable_request_body_raises(clients: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("An unencodable request body reached the network") + + with pytest.raises(TypeSafeError, match="could not be encoded as JSON"): + await system_one( + clients(handler), + state="x", + questions={"q": {"type": "noul", "instructions": "?"}}, + extra_body=cast(Any, {"bad": object()}), + ) + + +async def test_raw_question_passthrough(clients: ClientFactory) -> None: + questions: Questions = { + "q": {"type": "noul", "instructions": "Spam?", "weight": 3, "nested": {"k": None}}, + "choice": {"type": "choice", "criteria": {"a": None}, "weight": 2}, + "score": {"type": "score", "criteria": ["good"], "weight": 1}, + } + expected = {"state": "hi", "model": "jev-latest", "questions": questions} + + def handler(request: httpx2.Request) -> httpx2.Response: + assert msgspec.json.decode(request.content) == expected + return httpx2.Response(200, content=msgspec.json.encode(RESULT)) + + await system_one(clients(handler), state="hi", questions=questions) + + +@pytest.mark.parametrize( + "question", + [ + {"type": "noul", "instructions": 1}, + {"type": "choice", "criteria": ["invalid", "shape"]}, + Choice(criteria=cast(Any, ["invalid", "shape"])), + ], +) +async def test_question_schema_validation_is_left_to_api(clients: ClientFactory, question: Any) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert msgspec.json.decode(request.content)["questions"]["q"] == msgspec.to_builtins(question) + return httpx2.Response(422, json={"detail": "Invalid question"}) + + with pytest.raises(TypeSafeUnprocessableEntityError, match="Invalid question"): + await system_one(clients(handler), state="x", questions={"q": question}) + + +async def test_rich_descriptions(clients: ClientFactory) -> None: + criteria = {"summary": "duplicated", "examples": ["charged twice"]} + expected = { + "state": "a ticket", + "model": "custom", + "questions": { + "duplicate": {"type": "noul", "instructions": {"question": "Duplicate?"}, "criteria": {"true": criteria}}, + "team": {"type": "choice", "instructions": "Team?", "criteria": {"billing": criteria, "other": None}}, + "risk": {"type": "score", "instructions": "Risk?", "criteria": [criteria]}, + }, + } + + def handler(request: httpx2.Request) -> httpx2.Response: + assert msgspec.json.decode(request.content) == expected + return httpx2.Response( + 200, + json={ + "model": "custom", + "usage": {"input_tokens": 1, "output_tokens": 1}, + "answers": { + "risk": {"type": "score", "score": 0, "confidence": 1, "legend": {"0": criteria}, "probabilities": {"0": 1}}, + }, + }, + ) + + result = await system_one( + clients(handler), + state="a ticket", + model="custom", + questions={ + "duplicate": { + "type": "noul", + "instructions": {"question": "Duplicate?"}, + "criteria": {"true": {"summary": "duplicated", "examples": ["charged twice"]}}, + }, + "team": Choice( + instructions="Team?", + criteria={"billing": {"summary": "duplicated", "examples": ["charged twice"]}, "other": None}, + ), + "risk": Score(instructions="Risk?", criteria=[{"summary": "duplicated", "examples": ["charged twice"]}]), + }, + ) + assert result.scores["risk"].legend[0] == criteria + + +async def test_models_shape(clients: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + assert request.url.path == "/v1/models" + assert request.content == b"" + return httpx2.Response(200, content=msgspec.json.encode({"models": [CARD]})) + + assert await models(clients(handler)) == (ModelMetadata(**CARD),) + + +async def test_models_ignore_unknown_fields(clients: ClientFactory) -> None: + card = {**CARD, "context_window": 128000, "pricing": None} + client = clients(lambda request: httpx2.Response(200, content=msgspec.json.encode({"models": [card]}))) + response = await client.models.list() if isinstance(client, AsyncTypeSafeClient) else client.models.list() + [model] = response.models + assert model.name == "jev-latest" + # Unmodeled fields are dropped from the card but remain available via the raw response. + assert response.raw_http_response.json()["models"][0]["context_window"] == 128000 + + +@pytest.mark.parametrize("body", [None, {}, {"models": "bad"}, {"models": [{"name": "x"}]}]) +async def test_invalid_models_response(clients: ClientFactory, body: object) -> None: + with pytest.raises(TypeSafeAPIResponseValidationError): + await models(clients(lambda request: httpx2.Response(200, json=body))) + + +@pytest.mark.parametrize( + "questions,match", + [ + ({}, "At least one question"), + ({"rating": Score(instructions="?", criteria=[])}, '"rating" has no criteria'), + ({"rating": Score(instructions="?", criteria={})}, '"rating" has no criteria'), + ({"rating": Score(instructions="?", criteria={0: "bad", 2: "good"})}, '"rating".*no gaps'), + ({"rating": Score(instructions="?", criteria={1: "bad"})}, '"rating".*no gaps'), + ({"rating": Score(instructions="?", criteria={-1: "bad"})}, '"rating".*non-negative integers'), + ], +) +async def test_validation_before_network(clients: ClientFactory, questions: Any, match: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("Invalid questions reached the network") + + with pytest.raises(TypeSafeError, match=match): + await system_one(clients(handler), state="x", questions=questions) + + +@pytest.mark.parametrize( + "status,error", + [ + (400, TypeSafeBadRequestError), + (401, TypeSafeAuthenticationError), + (403, TypeSafePermissionDeniedError), + (404, TypeSafeNotFoundError), + (422, TypeSafeUnprocessableEntityError), + (429, TypeSafeRateLimitError), + (500, TypeSafeInternalServerError), + (503, TypeSafeInternalServerError), + (408, TypeSafeAPIError), + (409, TypeSafeAPIError), + (302, TypeSafeAPIError), + ], +) +async def test_error_mapping(clients: ClientFactory, status: int, error: type[TypeSafeAPIError]) -> None: + body = {"detail": {"message": "Server explanation"}} + with pytest.raises(error) as caught: + await models( + clients(lambda request: httpx2.Response(status, json=body, headers={"x-typesafe-request-id": "req_123", "retry-after-ms": "125"})) + ) + assert type(caught.value) is error + assert caught.value.status == status + assert caught.value.body == body + assert caught.value.request_id == "req_123" + assert str(caught.value) == f"{status} Server explanation" + assert caught.value.headers["retry-after-ms"] == "125" + if isinstance(caught.value, TypeSafeRateLimitError): + assert caught.value.retry_after_ms == 125 + + +@pytest.mark.parametrize( + "body,message", + [ + ({"error": "error", "message": "message", "detail": "detail"}, "error"), + ({"error": {"message": "nested error"}, "message": "message"}, "nested error"), + ({"message": "message", "detail": "detail"}, "message"), + ({"detail": "detail"}, "detail"), + ({"detail": {"message": "nested detail"}}, "nested detail"), + ( + {"detail": [{"loc": ["body", "questions", "q", "score", "criteria", 0], "msg": "Invalid"}, {"msg": "Missing"}, {}]}, + "questions.q.score.criteria.0: Invalid; Missing", + ), + ("plain text", "plain text"), + ({"unexpected": True}, '{"unexpected":true}'), + ], +) +async def test_error_messages(clients: ClientFactory, body: object, message: str) -> None: + content = body.encode() if isinstance(body, str) else msgspec.json.encode(body) + with pytest.raises(TypeSafeAPIError, match="400") as caught: + await models(clients(lambda request: httpx2.Response(400, content=content))) + assert str(caught.value) == f"400 {message}" + + +@pytest.mark.parametrize( + "transport_error,sdk_error", + [ + (httpx2.ConnectError, TypeSafeAPIConnectionError), + (httpx2.ReadError, TypeSafeAPIConnectionError), + (httpx2.RemoteProtocolError, TypeSafeAPIConnectionError), + (httpx2.ConnectTimeout, TypeSafeAPITimeoutError), + (httpx2.ReadTimeout, TypeSafeAPITimeoutError), + ], +) +async def test_transport_errors(clients: ClientFactory, transport_error: type[httpx2.RequestError], sdk_error: type[TypeSafeError]) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + raise transport_error("failed", request=request) + + with pytest.raises(sdk_error) as caught: + await models(clients(handler), timeout=1.25) + assert isinstance(caught.value.__cause__, transport_error) + if isinstance(caught.value, TypeSafeAPITimeoutError): + assert caught.value.timeout == 1.25 + + +@pytest.mark.parametrize("timeout", [None, 2.0, httpx2.Timeout(3.0, read=9.0), httpx2.Timeout(None)]) +async def test_system_one_timeout_override(clients: ClientFactory, timeout: float | httpx2.Timeout | None) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, json=RESULT) + + client = clients(handler, timeout=7.0) + await system_one(client, state="hello", questions={"q": Noul(instructions="?")}, timeout=timeout) + await system_one(client, state="hello", questions={"q": Noul(instructions="?")}) + expected = httpx2.Timeout(7.0 if timeout is None else timeout).as_dict() + assert requests[0].extensions["timeout"] == expected + assert requests[1].extensions["timeout"] == httpx2.Timeout(7.0).as_dict() + + +async def test_headers_timeout_and_logging(clients: ClientFactory, caplog: pytest.LogCaptureFixture) -> None: + protected = { + "authorization": "injected-secret", + "accept": "text/plain", + "user-agent": "wrong", + "x-typesafe-sdk": "wrong", + "x-typesafe-runtime": "wrong", + } + + def handler(request: httpx2.Request) -> httpx2.Response: + assert str(request.url) == "https://example.test/prefix/v1/systemone" + assert request.headers["authorization"] == "Bearer test-key" + assert request.headers["accept"] == "application/json" + assert request.headers["user-agent"] == f"typesafe-sdk/{version('typesafe-sdk')}" + assert request.headers["x-typesafe-sdk"] == request.headers["user-agent"] + assert request.headers["x-typesafe-runtime"].startswith("python/") + assert "x-typesafe-retry-count" not in request.headers + assert request.headers["x-team"] == "call" + assert request.headers["x-default"] == "kept" + assert request.headers["content-type"] == "application/json" + assert request.extensions["timeout"] == {"connect": 2.0, "read": 2.0, "write": 2.0, "pool": 2.0} + return httpx2.Response(200, json=RESULT, headers={"set-cookie": "response-secret", "x-typesafe-request-id": "req_log"}) + + with caplog.at_level(logging.DEBUG, logger="typesafe_sdk"): + client = clients( + handler, + base_url="https://example.test/prefix///", + timeout=7, + headers={**protected, "X-Team": "default", "X-Default": "kept", "X-API-Key": "key-secret", "cookie": "cookie-secret"}, + ) + await system_one( + client, + state="hello", + questions={"q": {"type": "noul", "instructions": "?"}}, + timeout=2.0, + extra_headers={**protected, "x-team": "call", "x-typesafe-retry-count": "99", "content-type": "wrong"}, + ) + for secret in ("test-key", "injected-secret", "key-secret", "cookie-secret", "response-secret"): + assert secret not in caplog.text + assert "req_log" in caplog.text + assert "hello" in caplog.text + + +async def test_http_client_settings(clients: ClientFactory) -> None: + requests: list[httpx2.Request] = [] + + async def async_hook(request: httpx2.Request) -> None: + requests.append(request) + + def handler(request: httpx2.Request) -> httpx2.Response: + assert str(request.url).startswith("https://api.typesafe.ai/v1/") + assert request.headers["authorization"] == "Bearer test-key" + assert request.headers["accept"] == "application/json" + assert request.headers["user-agent"].startswith("typesafe-sdk/") + assert request.headers["x-typesafe-sdk"] == request.headers["user-agent"] + assert request.headers["x-typesafe-runtime"].startswith("python/") + assert request.headers["x-http-default"] == "kept" + assert request.headers["x-sdk-default"] == "sdk" + assert request.headers["x-call"] == "call" + if request.method == "POST": + assert request.headers["content-type"] == "application/json" + return httpx2.Response(200, json=RESULT if request.method == "POST" else {"models": []}) + + headers = { + "authorization": "wrong", + "accept": "text/plain", + "content-type": "text/plain", + "user-agent": "wrong", + "x-typesafe-sdk": "wrong", + "x-typesafe-runtime": "wrong", + "x-http-default": "kept", + "x-sdk-default": "http", + "x-call": "http", + } + http_client = ( + httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + event_hooks={"request": [async_hook]}, + headers=headers, + auth=("wrong", "credentials"), + base_url="https://wrong.test", + ) + if clients.async_mode + else httpx2.Client( + transport=httpx2.MockTransport(handler), + event_hooks={"request": [requests.append]}, + headers=headers, + auth=("wrong", "credentials"), + base_url="https://wrong.test", + ) + ) + original_headers = httpx2.Headers(http_client.headers) + original_base_url = http_client.base_url + client = clients(handler, http_client=http_client, headers={"x-sdk-default": "sdk", "x-call": "sdk"}) + assert client._http_client is http_client + assert await models(client, extra_headers={"x-call": "call"}) == () + await system_one(client, state="x", questions={"q": Noul(instructions="?")}, extra_headers={"x-call": "call"}) + assert [request.method for request in requests] == ["GET", "POST"] + assert http_client.headers == original_headers + assert http_client.base_url == original_base_url + + +@pytest.mark.parametrize("use_context", [False, True]) +@pytest.mark.parametrize("supply_http_client", [False, True]) +async def test_supplied_network_resources_closed(clients: ClientFactory, use_context: bool, supply_http_client: bool) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert str(request.url) == "https://api.typesafe.ai/v1/models" + assert request.headers["authorization"] == "Bearer test-key" + return httpx2.Response(200, json={"models": []}) + + transport = TrackingTransport(handler) + if supply_http_client: + http_client = httpx2.AsyncClient(transport=transport) if clients.async_mode else httpx2.Client(transport=transport) + client = clients(handler, http_client=http_client) + assert client._http_client is http_client + else: + client = clients(handler, transport=transport) + assert await models(client) == () + if isinstance(client, AsyncTypeSafeClient): + if use_context: + async with client: + assert (await client.models.list()).models == () + else: + await client.aclose() + await client.aclose() + assert transport.aclose_calls == 1 + assert transport.close_calls == 0 + else: + if use_context: + with client: + assert client.models.list().models == () + else: + client.close() + client.close() + assert transport.close_calls == 1 + assert transport.aclose_calls == 0 + assert client._http_client.is_closed + with pytest.raises(RuntimeError, match="closed"): + await models(client) + + +async def test_owned_http_client_closed() -> None: + with TypeSafeClient(api_key="test") as client: + assert not client._http_client.is_closed + assert client._http_client.timeout == httpx2.Timeout(10.0) + assert client._http_client.is_closed + async with AsyncTypeSafeClient(api_key="test") as async_client: + assert not async_client._http_client.is_closed + assert async_client._http_client.timeout == httpx2.Timeout(10.0) + assert async_client._http_client.is_closed + + +@pytest.mark.parametrize("kind", [ValueError, asyncio.CancelledError]) +async def test_exceptional_context_closes_http_client(clients: ClientFactory, kind: type[BaseException]) -> None: + failure = kind("original failure") + + def handler(request: httpx2.Request) -> httpx2.Response: + raise failure + + transport = TrackingTransport(handler) + http_client = httpx2.AsyncClient(transport=transport) if clients.async_mode else httpx2.Client(transport=transport) + client = clients(handler, http_client=http_client) + assert client._http_client is http_client + with pytest.raises(kind) as caught: # noqa: PT012 - Exercise context-manager cleanup while propagating the exception. + if isinstance(client, AsyncTypeSafeClient): + async with client: + await client.models.list() + else: + with client: + client.models.list() + assert caught.value is failure + assert transport.aclose_calls == int(clients.async_mode) + assert transport.close_calls == int(not clients.async_mode) + assert client._http_client.is_closed + + +async def test_task_cancellation_closes_context() -> None: + started = asyncio.Event() + + async def handler(request: httpx2.Request) -> httpx2.Response: + started.set() + await asyncio.Event().wait() + raise AssertionError("Cancelled request returned") + + transport = TrackingTransport(handler) + client = AsyncTypeSafeClient(api_key="test", http_client=httpx2.AsyncClient(transport=transport)) + + async def run() -> None: + async with client: + await client.system_one("x", {"q": Noul(instructions="?")}) + + task = asyncio.create_task(run()) + try: + await asyncio.wait_for(started.wait(), timeout=1) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert transport.aclose_calls == 1 + assert client._http_client.is_closed + + +async def test_cancellation_propagates() -> None: + attempts = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + raise asyncio.CancelledError + + async with AsyncTypeSafeClient(api_key="test", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) as client: + with pytest.raises(asyncio.CancelledError): + await client.models.list() + assert attempts == 1 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3b2ec32 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,134 @@ +import httpx2 +import msgspec +import pytest + +from tests.conftest import ClientFactory +from tests.helpers import models, system_one +from tests.test_clients import RESULT +from typesafe_sdk import AsyncTypeSafeClient, TypeSafeClient, TypeSafeError + + +async def test_transport_and_http_client_mutually_exclusive(clients: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("Unexpected request") + + transport = httpx2.MockTransport(handler) + http_client = httpx2.AsyncClient(transport=transport) if clients.async_mode else httpx2.Client(transport=transport) + try: + with pytest.raises(ValueError, match="transport and http_client are mutually exclusive"): + clients(handler, transport=transport, http_client=http_client) + assert not http_client.is_closed + finally: + if isinstance(http_client, httpx2.AsyncClient): + await http_client.aclose() + else: + http_client.close() + + +@pytest.mark.parametrize("model", [None, "request-model"]) +async def test_model_override(clients: ClientFactory, model: str | None) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert msgspec.json.decode(request.content)["model"] == (model or "client-model") + return httpx2.Response(200, json=RESULT) + + await system_one( + clients(handler, model="client-model"), + state="hello", + questions={"q": {"type": "noul", "instructions": "?"}}, + model=model, + ) + + +@pytest.mark.parametrize("source", ["default", "env", "constructor"]) +async def test_resolution(clients: ClientFactory, monkeypatch: pytest.MonkeyPatch, source: str) -> None: + if source != "default": + monkeypatch.setenv("TYPESAFE_API_KEY", " env-key ") + monkeypatch.setenv("TYPESAFE_BASE_URL", " https://env.test/// ") + monkeypatch.setenv("TYPESAFE_DEFAULT_MODEL", " env-model ") + expected_key = "code-key" if source == "constructor" else "env-key" if source == "env" else "test-key" + expected_url = "https://code.test" if source == "constructor" else "https://env.test" if source == "env" else "https://api.typesafe.ai" + expected_model = "code-model" if source == "constructor" else "env-model" if source == "env" else "jev-latest" + + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers["authorization"] == f"Bearer {expected_key}" + assert str(request.url) == expected_url + "/v1/systemone" + assert msgspec.json.decode(request.content)["model"] == expected_model + assert request.extensions["timeout"] == {"connect": 10.0, "read": 10.0, "write": 10.0, "pool": 10.0} + return httpx2.Response(200, json=RESULT) + + if source == "constructor": + client = clients(handler, api_key="code-key", base_url="https://code.test///", model="code-model") + elif source == "env": + client = clients(handler, api_key=None) + else: + client = clients(handler) + await system_one(client, state="hello", questions={"q": {"type": "noul", "instructions": "?"}}) + + +@pytest.mark.parametrize("client_type", [TypeSafeClient, AsyncTypeSafeClient]) +@pytest.mark.parametrize("key", [None, "", " \t\n "]) +def test_missing_key(client_type: type[TypeSafeClient] | type[AsyncTypeSafeClient], monkeypatch: pytest.MonkeyPatch, key: str | None) -> None: + if key is not None: + monkeypatch.setenv("TYPESAFE_API_KEY", key) + with pytest.raises(TypeSafeError, match="TYPESAFE_API_KEY"): + client_type() + + +async def test_empty_env_unset(clients: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_LOG_LEVEL"): + monkeypatch.setenv(name, " \t ") + + def handler(request: httpx2.Request) -> httpx2.Response: + assert str(request.url) == "https://api.typesafe.ai/v1/systemone" + assert msgspec.json.decode(request.content)["model"] == "jev-latest" + return httpx2.Response(200, json=RESULT) + + await system_one(clients(handler), state="x", questions={"q": {"type": "noul", "instructions": "?"}}) + + +@pytest.mark.parametrize("value", [0, -1, float("inf"), float("nan")]) +async def test_invalid_timeout(clients: ClientFactory, value: float) -> None: + with pytest.raises(TypeSafeError, match="timeout"): + clients(lambda request: httpx2.Response(200), timeout=value) + client = clients(lambda request: pytest.fail("Unexpected request")) + with pytest.raises(TypeSafeError, match="timeout"): + await models(client, timeout=value) + + +async def test_timeout_object(clients: ClientFactory) -> None: + timeout = httpx2.Timeout(7.0, connect=1.0) + + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.extensions["timeout"] == {"connect": 1.0, "read": 7.0, "write": 7.0, "pool": 7.0} + return httpx2.Response(200, json={"models": []}) + + assert await models(clients(handler, timeout=timeout)) == () + + +@pytest.mark.parametrize("http_timeout", [httpx2.Timeout(17.0, connect=1.0), httpx2.Timeout(None)]) +@pytest.mark.parametrize("sdk_timeout,call_timeout", [(None, None), (3.0, None), (None, 2.0), (3.0, httpx2.Timeout(None))]) +async def test_http_client_timeout_precedence( + clients: ClientFactory, + http_timeout: httpx2.Timeout, + sdk_timeout: float | None, + call_timeout: float | httpx2.Timeout | None, +) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, json={"models": []}) + + http_client = ( + httpx2.AsyncClient(transport=httpx2.MockTransport(handler), timeout=http_timeout) + if clients.async_mode + else httpx2.Client(transport=httpx2.MockTransport(handler), timeout=http_timeout) + ) + client = clients(handler, http_client=http_client, timeout=sdk_timeout) + assert await models(client, timeout=call_timeout) == () + assert await models(client) == () + default = http_timeout if sdk_timeout is None else httpx2.Timeout(sdk_timeout) + expected = default if call_timeout is None else httpx2.Timeout(call_timeout) + assert requests[0].extensions["timeout"] == expected.as_dict() + assert requests[1].extensions["timeout"] == default.as_dict() + assert http_client.timeout == http_timeout diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..4f8b264 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,47 @@ +"""Execute Markdown and Python docstring examples against the configured API.""" + +import doctest +import importlib +from pathlib import Path + +import pytest +from sybil import Document +from sybil.document import PythonDocStringDocument +from sybil.evaluators.python import PythonEvaluator +from sybil.parsers.markdown import CodeBlockParser, PythonCodeBlockParser + +import typesafe_sdk + +ROOT = Path(__file__).resolve().parents[1] +MARKDOWN_FILES = [ROOT / "README.md", *sorted((ROOT / "docs").rglob("*.md"))] +SOURCE_FILES = sorted((ROOT / "src" / "typesafe_sdk").rglob("*.py")) + + +def code_parsers() -> tuple[PythonCodeBlockParser, CodeBlockParser]: + """Parse both ```python and ```py fenced blocks with the same Python evaluator.""" + return PythonCodeBlockParser(), CodeBlockParser("py", PythonEvaluator()) + + +@pytest.mark.integration +@pytest.mark.usefixtures("live_api_key") +@pytest.mark.parametrize("path", MARKDOWN_FILES, ids=lambda path: str(path.relative_to(ROOT))) +def test_markdown(path: Path) -> None: + document = Document.parse(str(path), *code_parsers()) + for example in document.examples(): + example.evaluate() + + +@pytest.mark.parametrize("path", SOURCE_FILES, ids=lambda path: str(path.relative_to(ROOT))) +@pytest.mark.integration +@pytest.mark.usefixtures("live_api_key") +def test_python_doctests(path: Path) -> None: + document = PythonDocStringDocument.parse(str(path), *code_parsers()) + # Docstring examples are shown on the documented symbol's own page, so they may reference public + # names (like the client) without importing them; standalone Markdown pages still import everything. + document.namespace.update({export: getattr(typesafe_sdk, export) for export in typesafe_sdk.__all__}) + for example in document.examples(): + example.evaluate() + parts = path.relative_to(ROOT / "src").with_suffix("").parts + name = ".".join(parts[:-1] if parts[-1] == "__init__" else parts) + result = doctest.testmod(importlib.import_module(name)) + assert result.failed == 0 diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..e1c5744 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,63 @@ +import httpx2 +import pytest + +from tests.conftest import ClientFactory +from tests.helpers import models +from typesafe_sdk import ( + TypeSafeAPIError, + TypeSafeAuthenticationError, + TypeSafeBadRequestError, + TypeSafeInternalServerError, + TypeSafeNotFoundError, + TypeSafePermissionDeniedError, + TypeSafeRateLimitError, + TypeSafeUnprocessableEntityError, +) + + +@pytest.mark.parametrize( + "error_type", + [ + TypeSafeAPIError, + TypeSafeBadRequestError, + TypeSafeAuthenticationError, + TypeSafePermissionDeniedError, + TypeSafeNotFoundError, + TypeSafeUnprocessableEntityError, + TypeSafeRateLimitError, + TypeSafeInternalServerError, + ], +) +@pytest.mark.parametrize("message", ["A custom explanation", ""]) +def test_message_override(error_type: type[TypeSafeAPIError], message: str) -> None: + headers = httpx2.Headers({"retry-after-ms": "125"}) + body = {"message": "Server explanation"} + error = error_type(429, body, headers, message=message) + assert str(error) == message + assert error.status == 429 + assert error.body is body + assert error.headers is headers + assert error.request_id is None + if isinstance(error, TypeSafeRateLimitError): + assert error.retry_after_ms == 125 + + +@pytest.mark.parametrize( + "body,message", + [ + (b"", "400 status code (no body)"), + (b"null", "400 status code (no body)"), + (b"[]", "400 []"), + (b"42", "400 42"), + (b"not JSON: \xff", "400 not JSON: \ufffd"), + pytest.param(b"x" * 201, "400 " + "x" * 201, id="long-plain-message"), + pytest.param(b'{"unknown":"' + b"x" * 201 + b'"}', '400 {"unknown":"' + "x" * 188 + "…", id="long-unstructured-body"), + (b'{"error":"","message":"ignored"}', '400 {"error":"","message":"ignored"}'), + (b'{"detail":[null,42,{"msg":4}]}', '400 {"detail":[null,42,{"msg":4}]}'), + ], +) +async def test_error_body_edge_cases(clients: ClientFactory, body: bytes, message: str) -> None: + with pytest.raises(TypeSafeAPIError) as caught: + await models(clients(lambda request: httpx2.Response(400, content=body))) + assert str(caught.value) == message + assert caught.value.request_id is None diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..65f943a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,42 @@ +import pytest + +from tests.conftest import Client +from tests.helpers import models, system_one +from typesafe_sdk import Choice, Score + +pytestmark = pytest.mark.integration + + +async def test_live_models(live_client: Client) -> None: + available = await models(live_client) + assert available + for model in available: + assert isinstance(model.name, str) + assert isinstance(model.description, str) + assert isinstance(model.release_date, str) + + +async def test_live_questions(live_client: Client) -> None: + result = await system_one( + live_client, + state={"subject": "Charged twice this month", "body": "I see two charges of $49. I only have one account. Please fix this ASAP."}, + questions={ + "billing": { + "type": "noul", + "instructions": "Is this ticket about billing?", + "criteria": {"true": {"meaning": "Payments or invoices", "examples": ["charged twice"]}}, + }, + "tone": Choice(instructions="What is the customer's tone?", criteria={"calm": None, "frustrated": None, "angry": None}), + "urgency": Score(instructions="How urgent is this ticket?", criteria={0: "can wait", 1: "this week", 2: "today"}), + }, + ) + assert result.model + assert result.usage.input_tokens is None or result.usage.input_tokens > 0 + assert result.usage.output_tokens is None or result.usage.output_tokens >= 0 + assert 0 <= result.nouls["billing"].noul <= 1 + assert result.choices["tone"].choice in {"calm", "frustrated", "angry"} + assert sum(result.choices["tone"].probabilities.values()) == pytest.approx(1, abs=0.1) + assert 0 <= result.scores["urgency"].score <= 2 + assert result.scores["urgency"].legend == {0: "can wait", 1: "this week", 2: "today"} + assert set(result.scores["urgency"].probabilities) == {0, 1, 2} + assert sum(result.scores["urgency"].probabilities.values()) == pytest.approx(1, abs=0.1) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..eee8d4f --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,112 @@ +import logging + +import httpx2 +import pytest + +from tests.conftest import ClientFactory +from tests.helpers import models +from typesafe_sdk import RetryPolicy, TypeSafeAPIError +from typesafe_sdk._core.logging import logger, setup_logging + + +@pytest.mark.parametrize("status", [200, 400, 429]) +@pytest.mark.parametrize( + "header", + [ + "Authorization", + "Proxy-Authorization", + "X-API-Key", + "API-Key", + "Cookie", + "Set-Cookie", + "X-Access-Token", + "X-Client-Secret", + "x-MiXeD-ToKeN", + ], +) +async def test_secret_headers_redacted( + clients: ClientFactory, + caplog: pytest.LogCaptureFixture, + status: int, + header: str, +) -> None: + attempts = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + return httpx2.Response( + status, + json={"models": []} if status == 200 else {"message": "failure"}, + headers={header: "response-credential", "x-visible": "response-visible"}, + ) + + policy = RetryPolicy(backoff_initial=0.001, backoff_max=0.001) + with caplog.at_level(logging.DEBUG, logger="typesafe_sdk"): + client = clients( + handler, + api_key="auth-credential", + headers={header: "request-credential", "x-visible": "request-visible"}, + retry=policy, + ) + if status == 200: + await models(client) + else: + with pytest.raises(TypeSafeAPIError): + await models(client) + assert attempts == (3 if status == 429 else 1) + assert "request-visible" in caplog.text + assert "response-visible" in caplog.text + assert "***" in caplog.text + for secret in ("auth-credential", "request-credential", "response-credential"): + assert secret not in caplog.text + if status == 429: + assert "retry 1" in caplog.text + assert "retry 2" in caplog.text + + +@pytest.mark.parametrize( + "level,expected", + [ + (logging.DEBUG, {logging.DEBUG, logging.INFO}), + (logging.INFO, {logging.INFO}), + (logging.WARNING, set()), + ], +) +async def test_logger_level_controls_output( + clients: ClientFactory, + caplog: pytest.LogCaptureFixture, + level: int, + expected: set[int], +) -> None: + with caplog.at_level(level, logger="typesafe_sdk"): + await models(clients(lambda request: httpx2.Response(200, json={"models": []}))) + records = [record for record in caplog.records if record.name == "typesafe_sdk"] + assert {record.levelno for record in records} == expected + if logging.INFO in expected: + summaries = [record.getMessage() for record in records if record.levelno == logging.INFO] + assert len(summaries) == 1 + assert "GET" in summaries[0] + if logging.DEBUG not in expected: + assert all("headers=" not in record.getMessage() for record in records) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("debug", logging.DEBUG), + ("info", logging.INFO), + ("off", logging.CRITICAL + 1), + ("bogus", logging.NOTSET), + ("", logging.NOTSET), + ], +) +def test_setup_logging_from_env(monkeypatch: pytest.MonkeyPatch, value: str, expected: int) -> None: + original = logger.level + try: + logger.setLevel(logging.NOTSET) + monkeypatch.setenv("TYPESAFE_LOG_LEVEL", value) + setup_logging() + assert logger.level == expected + finally: + logger.setLevel(original) diff --git a/tests/test_public_api_surface.py b/tests/test_public_api_surface.py new file mode 100644 index 0000000..8797216 --- /dev/null +++ b/tests/test_public_api_surface.py @@ -0,0 +1,25 @@ +import inspect +from types import ModuleType + +import pytest +from syrupy.assertion import SnapshotAssertion + +import typesafe_sdk +from typesafe_sdk import AsyncTypeSafeClient, TypeSafeClient + + +@pytest.mark.parametrize("module", [typesafe_sdk, typesafe_sdk.constants], ids=lambda module: module.__name__) +def test_public_members(module: ModuleType, snapshot: SnapshotAssertion) -> None: + assert sorted(name for name in vars(module) if not name.startswith("_")) == snapshot + + +def test_package_exports() -> None: + assert not any(name.startswith("_") for name in typesafe_sdk.__all__) + + +@pytest.mark.parametrize("client_type", [AsyncTypeSafeClient, TypeSafeClient]) +def test_constructor_kwargs(client_type: type[AsyncTypeSafeClient] | type[TypeSafeClient], snapshot: SnapshotAssertion) -> None: + parameters = inspect.signature(client_type).parameters + assert list(parameters) == snapshot + assert all(parameter.kind is inspect.Parameter.KEYWORD_ONLY for parameter in parameters.values()) + assert all(parameter.default is None for parameter in parameters.values()) diff --git a/tests/test_public_sync.py b/tests/test_public_sync.py new file mode 100644 index 0000000..6856a96 --- /dev/null +++ b/tests/test_public_sync.py @@ -0,0 +1,324 @@ +"""Test snapshot filtering and release retries.""" + +import base64 +import importlib +import os +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / ".github/scripts" +INCLUDES = "pyproject.toml\nsrc/\ndocs/changelog.md\n.github/workflows/publish.yml\n" + +pytestmark = pytest.mark.skipif( + not (SCRIPTS / "sync_public.py").is_file(), reason="Public sync tooling is only available in the dev repository" +) + + +def git(repo: Path, *args: str) -> str: + return subprocess.check_output( # noqa: S603 - Explicit Git arguments in disposable fixtures. + ["git", *args], # noqa: S607 - Git is supplied by CI. + cwd=repo, + text=True, + stderr=subprocess.PIPE, + ).strip() + + +def write(repo: Path, path: str, content: str) -> None: + file = repo / path + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(content) + + +def commit(repo: Path, message: str = "private work") -> None: + git(repo, "add", ".") + git(repo, "commit", "--allow-empty", "-qm", message) + + +def set_version(repo: Path, version: str) -> None: + write(repo, "pyproject.toml", f'[project]\nname = "typesafe-sdk"\nversion = "{version}"\n') + write(repo, "docs/changelog.md", f"## v{version} (2026-09-11)\n\nReviewed release notes.\n") + + +def run_script(source: Path, script: str, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - Repository-owned script with explicit fixture paths. + [sys.executable, str(SCRIPTS / script), *args], + cwd=source, + text=True, + capture_output=True, + check=False, + env={**os.environ, "UV_NO_CACHE": "1"}, + timeout=30, + ) + + +@pytest.fixture +def repos(tmp_path: Path) -> tuple[Path, Path, Path]: + source, destination, remote = tmp_path / "dev", tmp_path / "public", tmp_path / "remote.git" + for repo in [source, destination]: + repo.mkdir() + git(repo, "init", "-q", "--initial-branch=main") + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.com") + git(repo, "config", "commit.gpgsign", "false") + git(tmp_path, "init", "--bare", "--initial-branch=main", str(remote)) + git(destination, "remote", "add", "origin", str(remote)) + set_version(source, "42.0.0") + write(source, ".releaseinclude", INCLUDES) + write(source, "src/typesafe_sdk/__init__.py", "# public source\n") + write(source, ".github/workflows/publish.yml", "name: Publish\n") + write(source, ".github/workflows/CI.yml", "private CI\n") + write(source, ".dagger/private.py", "private infrastructure\n") + write(source, "RELEASING.md", "private instructions\n") + commit(source) + commit(source, "more private history") + return source, destination, remote + + +def sync(source: Path, destination: Path, tag: str = "v42.0.0") -> subprocess.CompletedProcess[str]: + return run_script(source, "sync_public.py", str(destination), tag, "--dry-run") + + +@pytest.fixture +def signer(repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch) -> ModuleType: + """Emulate GitHub's Git object API in the bare remote; use real Git for fetch/push.""" + _, destination, remote = repos + monkeypatch.syspath_prepend(str(SCRIPTS)) + module = importlib.import_module("sync_public") + + def blob(content: bytes) -> str: + return ( + subprocess.check_output( + ["git", "hash-object", "-w", "--stdin"], # noqa: S607 - Git is supplied by CI. + input=content, + cwd=remote, + ) + .decode() + .strip() + ) + + def api(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + if endpoint == "blobs": + assert payload["encoding"] == "base64" + return {"sha": blob(base64.b64decode(payload["content"]))} + if endpoint == "trees": + git(remote, "read-tree", "--empty") + for entry in payload["tree"]: + sha = blob(entry["content"].encode("utf-8")) if "content" in entry else entry["sha"] + git(remote, "update-index", "--add", "--cacheinfo", entry["mode"], sha, entry["path"]) + return {"sha": git(remote, "write-tree")} + assert endpoint == "commits" + # GitHub only signs when author, committer, and signature are omitted. + assert set(payload) == {"message", "tree", "parents"} + # The API creates the release commit directly, without an interim local commit. + head = module.Repo(destination).head + assert (head.commit.hexsha if head.is_valid() else None) == (payload["parents"][0] if payload["parents"] else None) + parents = [arg for parent in payload["parents"] for arg in ["-p", parent]] + sha = git( + remote, + "-c", + "user.name=release-app[bot]", + "-c", + "user.email=release-app[bot]@users.noreply.github.com", + "commit-tree", + payload["tree"], + *parents, + "-m", + payload["message"], + ) + return {"sha": sha, "verification": {"verified": True, "reason": "valid"}} + + monkeypatch.setattr(module, "api", api) + return module + + +@pytest.mark.parametrize("with_history", [False, True]) +def test_sign_snapshot_and_push(repos: tuple[Path, Path, Path], signer: ModuleType, with_history: bool) -> None: + source, destination, remote = repos + if with_history: + write(destination, "obsolete.txt", "delete me") + commit(destination, "public history") + git(destination, "push", "origin", "main") + write(source, "src/run.sh", "#!/bin/sh\necho 'héllo'\n") + (source / "src/run.sh").chmod(0o755) + (source / "src/image.bin").write_bytes(b"\x00\xff\x80\r\n") + commit(source) + parent = git(destination, "rev-parse", "HEAD") if with_history else "" + remote_refs = git(remote, "show-ref", "--head") if with_history else "" + + assert signer.sync_public(source, destination, "v42.0.0", dry_run=False) + + signed = git(destination, "rev-parse", "HEAD") + assert git(destination, "log", "-1", "--format=%P") == parent + assert git(destination, "rev-list", "--count", "HEAD") == ("2" if with_history else "1") + assert git(destination, "log", "-1", "--format=%s") == "Release v42.0.0" + assert (destination / "src/run.sh").read_text() == "#!/bin/sh\necho 'héllo'\n" + assert (destination / "src/run.sh").stat().st_mode & 0o777 == 0o755 + assert (destination / "src/image.bin").read_bytes() == b"\x00\xff\x80\r\n" + assert not (destination / "obsolete.txt").exists() + assert not (destination / ".dagger").exists() + assert git(destination, "rev-parse", "v42.0.0") == signed + assert git(destination, "status", "--porcelain") == "" + assert git(remote, "for-each-ref", "--format=%(refname)") == ("refs/heads/main" if with_history else "") + if with_history: + assert git(remote, "show-ref", "--head") == remote_refs + result = run_script(source, "push_public.py", str(destination), "v42.0.0") + assert result.returncode == 0, result.stderr + assert git(remote, "rev-parse", "main") == signed + assert git(remote, "rev-parse", "v42.0.0") == signed + assert not signer.sync_public(source, destination, "v42.0.0", dry_run=False) + assert git(destination, "rev-parse", "v42.0.0") == signed + + +@pytest.mark.parametrize("failure", ["tree", "signature"]) +def test_signing_failure_keeps_refs( + repos: tuple[Path, Path, Path], + signer: ModuleType, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + source, destination, remote = repos + commit(destination, "public history") + git(destination, "push", "origin", "main") + original = git(destination, "show-ref", "--head") + remote_refs = git(remote, "show-ref", "--head") + create_object = signer.api + + def fail(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + result = create_object(endpoint, payload) + if endpoint == "trees" and failure == "tree": + result["sha"] = "0" * 40 + if endpoint == "commits": + result["verification"] = {"verified": False, "reason": "unsigned"} + return result + + monkeypatch.setattr(signer, "api", fail) + with pytest.raises(ValueError, match=r"does not match|did not verify"): + signer.sync_public(source, destination, "v42.0.0", dry_run=False) + assert git(destination, "show-ref", "--head") == original + assert git(remote, "show-ref", "--head") == remote_refs + + +def test_dry_run_skips_github(repos: tuple[Path, Path, Path], signer: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None: + source, destination, remote = repos + + def fail(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + pytest.fail("Dry runs must not call the GitHub API") + + monkeypatch.setattr(signer, "api", fail) + assert signer.sync_public(source, destination, "v42.0.0", dry_run=True) + assert git(destination, "log", "-1", "--format=%s") == "Release v42.0.0" + assert git(remote, "for-each-ref", "--format=%(refname)") == "" + + +def test_snapshot_and_push_retries(repos: tuple[Path, Path, Path]) -> None: + source, destination, remote = repos + # Export the committed version despite local edits. + write(source, "src/untracked.py", "private draft") + write(source, "src/typesafe_sdk/__init__.py", "uncommitted edits") + set_version(source, "99.0.0") + result = sync(source, destination) + assert result.returncode == 0, result.stderr + assert git(destination, "rev-list", "--count", "HEAD") == "1" + assert git(destination, "log", "-1", "--format=%s") == "Release v42.0.0" + assert (destination / "src/typesafe_sdk/__init__.py").read_text() == "# public source\n" + assert not any( + (destination / path).exists() for path in [".dagger", "RELEASING.md", ".releaseinclude", "src/untracked.py", ".github/workflows/CI.yml"] + ) + head = git(destination, "rev-parse", "HEAD") + assert git(destination, "rev-parse", "v42.0.0") == head + assert sync(source, destination).returncode == 0 + dry_run = run_script(source, "push_public.py", str(destination), "v42.0.0", "--dry-run") + assert dry_run.returncode == 0, dry_run.stderr + assert git(remote, "for-each-ref", "--format=%(refname)") == "" + for _ in range(2): + pushed = run_script(source, "push_public.py", str(destination), "v42.0.0") + assert pushed.returncode == 0, pushed.stderr + assert git(remote, "rev-parse", "main") == head + assert git(remote, "rev-parse", "v42.0.0") == head + assert git(remote, "branch", "--list") == "* main" + + +def test_existing_history_deletions_and_immutable_tags(repos: tuple[Path, Path, Path]) -> None: + source, destination, _ = repos + write(destination, "old-private-config", "previously committed infrastructure") + commit(destination, "existing destination history") + assert sync(source, destination).returncode == 0 + assert not (destination / "old-private-config").exists() + released = git(destination, "rev-parse", "HEAD") + assert git(destination, "rev-list", "--count", "HEAD") == "2" + write(source, "src/added.py", "new public code") + commit(source) + result = sync(source, destination) + assert result.returncode != 0 + assert "different contents" in result.stderr + assert git(destination, "rev-parse", "v42.0.0") == released + set_version(source, "42.0.2") + commit(source) + assert sync(source, destination, "v42.0.2").returncode == 0 + newest = git(destination, "rev-parse", "main") + set_version(source, "42.0.1") + commit(source) + result = sync(source, destination, "v42.0.1") + assert result.returncode != 0 + assert "Refusing" in result.stderr + git(source, "checkout", "HEAD~3") + assert sync(source, destination).returncode == 0 + assert git(destination, "rev-parse", "main") == newest + + +@pytest.mark.parametrize("include", ["../private", ".git/config", "src/*", ".releaseinclude", "/src", "missing-file"]) +def test_invalid_includes(repos: tuple[Path, Path, Path], include: str) -> None: + source, destination, _ = repos + write(source, ".releaseinclude", INCLUDES + include + "\n") + commit(source) + result = sync(source, destination) + assert result.returncode != 0 + assert "Invalid public include" in result.stderr or "matches no committed files" in result.stderr + assert git(destination, "tag", "--list") == "" + + +def test_unsafe_snapshots(repos: tuple[Path, Path, Path]) -> None: + source, destination, _ = repos + (source / "src/link").symlink_to("../RELEASING.md") + commit(source) + result = sync(source, destination) + assert result.returncode != 0 + assert "Unsupported public file mode" in result.stderr + write(destination, "keep.txt", "local work") + result = sync(source, destination) + assert result.returncode != 0 + assert "must be clean" in result.stderr + assert (destination / "keep.txt").read_text() == "local work" + + +def test_version_mismatch(repos: tuple[Path, Path, Path]) -> None: + source, destination, _ = repos + result = sync(source, destination, "v42.0.1") + assert result.returncode != 0 + assert "does not match package version" in result.stderr + + +def test_atomic_push_rejects_concurrent_update(repos: tuple[Path, Path, Path], signer: ModuleType) -> None: + source, destination, remote = repos + assert sync(source, destination).returncode == 0 + assert run_script(source, "push_public.py", str(destination), "v42.0.0").returncode == 0 + set_version(source, "42.0.1") + commit(source) + assert signer.sync_public(source, destination, "v42.0.1", dry_run=False) + # Simulate a concurrent update to public main. + competitor = source.parent / "competitor" + git(source.parent, "clone", str(remote), str(competitor)) + git(competitor, "config", "user.name", "Test") + git(competitor, "config", "user.email", "test@example.com") + git(competitor, "config", "commit.gpgsign", "false") + commit(competitor, "concurrent main change") + git(competitor, "push", "origin", "main") + result = run_script(source, "push_public.py", str(destination), "v42.0.1") + assert result.returncode != 0 + assert git(remote, "tag", "--list") == "v42.0.0" + assert git(remote, "rev-parse", "main") == git(competitor, "rev-parse", "HEAD") diff --git a/tests/test_questions.py b/tests/test_questions.py new file mode 100644 index 0000000..790bd94 --- /dev/null +++ b/tests/test_questions.py @@ -0,0 +1,221 @@ +import copy +import inspect +from collections.abc import Mapping +from types import MappingProxyType +from typing import Any, cast + +import httpx2 +import msgspec +import pytest + +from tests.conftest import ClientFactory +from typesafe_sdk import ( + AsyncTypeSafeClient, + Choice, + ChoiceModel, + Noul, + NoulCriteria, + NoulModel, + Question, + Questions, + Score, + ScoreModel, + TypeSafeError, +) +from typesafe_sdk._core.questions import normalize_questions +from typesafe_sdk._schemas import models as wire + + +def test_normalization_preserves_objects_except_score_maps() -> None: + questions = { + "noul": Noul(instructions="Spam?"), + "choice": Choice(instructions="Tone?", criteria={"calm": None}), + "score": Score(instructions="Quality?", criteria={1: "good", 0: "bad"}), + "list_score": Score(criteria=["bad", "good"]), + } + result = normalize_questions(questions) + assert result["noul"] is questions["noul"] + assert result["choice"] is questions["choice"] + assert result["list_score"] is questions["list_score"] + assert result["score"] is not questions["score"] + assert isinstance(result["score"], Score) + assert questions["score"].criteria == {1: "good", 0: "bad"} + assert msgspec.json.decode(msgspec.json.encode(result)) == { + "noul": {"type": "noul", "instructions": "Spam?"}, + "choice": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}, + "score": {"type": "score", "instructions": "Quality?", "criteria": ["bad", "good"]}, + "list_score": {"type": "score", "criteria": ["bad", "good"]}, + } + + +@pytest.mark.parametrize( + "raw", + [ + {"type": "noul", "instructions": "Spam?", "weight": 3, "criteria": {"future": "kept"}}, + {"type": "future", "nested": {"k": None}}, + {"type": "score", "criteria": {0: "good"}, "weight": 3}, + {"type": "score", "criteria": ["good"], "weight": 3}, + ], +) +def test_normalization_preserves_raw_questions(raw: dict[str, Any]) -> None: + before = copy.deepcopy(raw) + questions = cast(Questions, {"raw": raw, "typed": Noul(instructions="Spam?")}) + result = normalize_questions(questions) + expected = {**raw, "criteria": ["good"]} if raw["type"] == "score" else raw + assert isinstance(result["typed"], wire.NoulQuestion) + assert msgspec.to_builtins(result) == {"raw": expected, "typed": {"type": "noul", "instructions": "Spam?"}} + assert raw == before + if not isinstance(raw.get("criteria"), dict) or raw["type"] != "score": + assert result["raw"] is raw + + +@pytest.mark.parametrize( + "invalid", + [ + {}, + {"instructions": "Missing type"}, + {"type": "choice"}, + {"type": "score"}, + {"type": ""}, + {"type": None}, + {"type": 1}, + {"type": ["future"]}, + "noul", + None, + ], +) +def test_raw_questions_require_structural_keys(invalid: object) -> None: + with pytest.raises(TypeSafeError, match='Question "invalid"'): + normalize_questions(cast(Questions, {"invalid": invalid})) + + +@pytest.mark.parametrize( + "question,expected", + [ + (Noul(), {"type": "noul"}), + (Choice(criteria={"a": None}), {"type": "choice", "criteria": {"a": None}}), + (Score(criteria=["good"]), {"type": "score", "criteria": ["good"]}), + (Noul(instructions="", criteria={}), {"type": "noul", "instructions": "", "criteria": {}}), + (Noul(instructions=[], criteria={"true": None}), {"type": "noul", "instructions": [], "criteria": {"true": None}}), + ], +) +def test_direct_encoding_omits_only_default_fields(question: Noul | Choice | Score, expected: dict[str, Any]) -> None: + assert msgspec.json.decode(msgspec.json.encode(question)) == expected + assert msgspec.to_builtins(question) == expected + + +def test_score_mutations_are_normalized_when_sending() -> None: + score = Score(criteria=["initial"]) + score.criteria = {1: "good", 0: "bad"} + assert msgspec.to_builtins(normalize_questions({"q": score})) == {"q": {"type": "score", "criteria": ["bad", "good"]}} + assert score.criteria == {1: "good", 0: "bad"} + score.criteria[3] = "gap" + with pytest.raises(TypeSafeError, match="no gaps"): + normalize_questions({"q": score}) + + +def test_discriminators_are_automatic() -> None: + noul = Noul(instructions="Spam?") + choice = Choice(instructions="Tone?", criteria={"calm": None}) + score = Score(instructions="Quality?", criteria=["good"]) + for question, wire_type, tag in ( + (noul, wire.NoulQuestion, "noul"), + (choice, wire.ChoiceQuestion, "choice"), + (score, wire.ScoreQuestion, "score"), + ): + assert isinstance(question, msgspec.Struct) + assert isinstance(question, wire_type) + assert question.__struct_config__.tag == tag + assert question.__struct_config__.tag_field == "type" + assert msgspec.to_builtins(question)["type"] == tag + assert msgspec.json.decode(msgspec.json.encode(question))["type"] == tag + assert not hasattr(question, "type") + assert not hasattr(question, "__dict__") + signature = inspect.signature(type(question)) + assert "type" not in signature.parameters + assert all(parameter.kind is inspect.Parameter.KEYWORD_ONLY for parameter in signature.parameters.values()) + with pytest.raises(TypeError): + cast(Any, type(question))("Spam?") + with pytest.raises(AttributeError): + cast(Any, question).type = "other" + question.instructions = "Updated?" + assert msgspec.to_builtins(question)["instructions"] == "Updated?" + + +@pytest.mark.parametrize("raw", [False, True]) +@pytest.mark.parametrize( + "criteria", + [ + None, + {}, + {"true": "Yes"}, + {"false": "No"}, + {"true": "Yes", "false": "No"}, + {"true": {"summary": "Unsolicited", "examples": ["Buy now"]}}, + ], +) +def test_optional_noul_criteria(raw: bool, criteria: NoulCriteria | None) -> None: + expected: NoulModel = {"type": "noul", "instructions": "Spam?"} + if criteria is not None: + expected["criteria"] = criteria + question: Question + if raw: + question = expected.copy() + else: + question = Noul(instructions="Spam?", criteria=criteria) + assert msgspec.to_builtins(normalize_questions({"q": question})) == {"q": expected} + + +@pytest.mark.parametrize("raw", [False, True]) +@pytest.mark.parametrize("criteria", [[], {}, {0: "bad", 2: "good"}, {-1: "bad"}, {False: "bad"}, {0: "bad", True: "good"}]) +def test_score_validation_preserves_inputs(raw: bool, criteria: list[str] | dict[int, str]) -> None: + model = cast(ScoreModel, {"type": "score", "instructions": "Quality?", "criteria": criteria}) + question = model if raw else Score(instructions=model["instructions"], criteria=model["criteria"]) + before = copy.deepcopy(criteria) + with pytest.raises(TypeSafeError, match='"rating"'): + normalize_questions({"rating": question}) + assert criteria == before + + +async def test_covariant_question_mappings(clients: ClientFactory) -> None: + nouls = {"q": Noul(instructions="Spam?")} + choices = {"q": Choice(instructions="Tone?", criteria={"calm": None})} + scores = {"q": Score(instructions="Quality?", criteria={0: "good"})} + raw_nouls: dict[str, NoulModel] = {"q": {"type": "noul", "instructions": "Spam?"}} + raw_choices: dict[str, ChoiceModel] = {"q": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}} + raw_scores: dict[str, ScoreModel] = {"q": {"type": "score", "instructions": "Quality?", "criteria": {0: "good"}}} + read_only: Mapping[str, Choice] = MappingProxyType(choices) + mixed: Questions = {"one": nouls["q"], "two": raw_choices["q"], "three": scores["q"]} + calls = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal calls + calls += 1 + assert msgspec.json.decode(request.content)["questions"] + return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}}) + + client = clients(handler) + if isinstance(client, AsyncTypeSafeClient): + await client.system_one("x", nouls) + await client.system_one("x", choices) + await client.system_one("x", scores) + await client.system_one("x", raw_nouls) + await client.system_one("x", raw_choices) + await client.system_one("x", raw_scores) + await client.system_one("x", read_only) + await client.system_one("x", mixed) + await client.system_one("x", {"q": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}}) + else: + client.system_one("x", nouls) + client.system_one("x", choices) + client.system_one("x", scores) + client.system_one("x", raw_nouls) + client.system_one("x", raw_choices) + client.system_one("x", raw_scores) + client.system_one("x", read_only) + client.system_one("x", mixed) + client.system_one("x", {"q": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}}) + assert calls == 9 + assert scores["q"].criteria == {0: "good"} + assert raw_scores["q"]["criteria"] == {0: "good"} + assert read_only["q"] is choices["q"] diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..293bb21 --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,64 @@ +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / ".github/scripts/release_notes.py" + + +def run_release_notes(tmp_path: Path, changelog: str, tag: str = "v1.2.3", version: str = "1.2.3") -> subprocess.CompletedProcess[str]: + (tmp_path / "docs").mkdir() + (tmp_path / "docs/changelog.md").write_text(changelog) + return subprocess.run( # noqa: S603 - Fixed interpreter and repository-owned script. + [sys.executable, str(SCRIPT), tag, version], + cwd=tmp_path, + env={**os.environ, "GITHUB_OUTPUT": str(tmp_path / "output")}, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + + +@pytest.mark.parametrize("unreleased", ["", "## Unreleased\n\nFuture work.\n\n"]) +def test_release_notes(tmp_path: Path, unreleased: str) -> None: + notes = "Custom **release notes**.\n\n### Features\n\n- Something new.\n" + changelog = ( + "---\ntitle: Changelog\n---\n\n# Changelog\n\n" + f"{unreleased}" + f"## v1.2.3 (2026-09-09)\n\n{notes}\n" + "## v1.2.2 (2026-09-08)\n\nOlder release.\n" + ) + result = run_release_notes(tmp_path, changelog) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "title=v1.2.3 (2026-09-09)\n" + assert (tmp_path / "release-notes.md").read_text() == notes + + +@pytest.mark.parametrize( + "changelog, tag, version, error", + [ + ("## v1.2.2 (2026-09-09)\nNotes.\n", "v1.2.3", "1.2.3", "found 0"), + ("## v1.2.30 (2026-09-09)\nNotes.\n", "v1.2.3", "1.2.3", "found 0"), + ( + "## v1.2.4 (2026-09-10)\nNew release.\n\n## v1.2.3 (2026-09-09)\nOlder release.\n", + "v1.2.3", + "1.2.3", + "must match the latest changelog entry", + ), + ("## v1.2.3 (2026-09-09)\nNotes.\n" * 2, "v1.2.3", "1.2.3", "found 2"), + ("## v1.2.3 (2026-09-09)\n\n", "v1.2.3", "1.2.3", "Empty release notes"), + ("## v1.2.3 (2026-02-30)\nNotes.\n", "v1.2.3", "1.2.3", "ValueError"), + ("## v1.2.3 (tomorrow)\nNotes.\n", "v1.2.3", "1.2.3", "Invalid release heading"), + ("", "v1.2.3", "1.2.4", "does not match package version"), + ("", "v1.2", "1.2", "vX.Y.Z format"), + ], +) +def test_invalid_release_notes(tmp_path: Path, changelog: str, tag: str, version: str, error: str) -> None: + result = run_release_notes(tmp_path, changelog, tag, version) + assert result.returncode != 0 + assert error in result.stderr + assert not (tmp_path / "release-notes.md").exists() + assert not (tmp_path / "output").exists() diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 0000000..e68d7c7 --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,202 @@ +from typing import Any, cast + +import httpx2 +import msgspec +import pytest +from typing_extensions import assert_type + +from tests.conftest import ClientFactory +from tests.helpers import system_one +from tests.test_clients import RESULT +from typesafe_sdk import ( + Answer, + ChoiceAnswer, + NoulAnswer, + ScoreAnswer, + SystemOneResponse, + TypeSafeAPIResponseValidationError, + TypeSafeError, + Usage, +) + + +@pytest.mark.parametrize( + "answers,field_path", + [ + ({}, "model"), + ({"n": {"type": "noul"}}, "answers.n.noul"), + ({"c": {"type": "choice", "choice": "a", "probabilities": {}}}, "answers.c.confidence"), + ({"c": {"type": "choice", "confidence": 0.5, "probabilities": {}}}, "answers.c.choice"), + ({"s": {"type": "score", "score": 1.0, "confidence": 1.0, "legend": [], "probabilities": {}}}, "answers.s.legend"), + ({"s": {"type": "score", "score": 1.0, "confidence": 1.0, "legend": {"x": "bad"}, "probabilities": {}}}, "answers.s.legend"), + ({"c": "not-a-mapping"}, "answers.c.type"), + ], +) +async def test_malformed_response_raises_validation_error(clients: ClientFactory, answers: dict[str, Any], field_path: str) -> None: + body: dict[str, Any] = {"usage": {"input_tokens": 1, "output_tokens": 1}, "answers": answers} + if field_path != "model": + body["model"] = "test" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-123"}) + + with pytest.raises(TypeSafeAPIResponseValidationError) as caught: + await system_one(clients(handler), state="x", questions={"q": {"type": "noul", "instructions": "?"}}) + assert caught.value.field_path == field_path + assert caught.value.status == 200 + assert caught.value.request_id == "req-123" + assert caught.value.body == body + + +async def test_response_carries_request_id(clients: ClientFactory) -> None: + result = await system_one( + clients(lambda request: httpx2.Response(200, json=RESULT, headers={"x-typesafe-request-id": "req-42"})), + state="text", + questions={"q": {"type": "noul", "instructions": "?"}}, + ) + assert result.request_id == "req-42" + + +async def test_response_carries_raw_http_response(clients: ClientFactory) -> None: + result = await system_one( + clients(lambda request: httpx2.Response(200, json=RESULT, headers={"x-typesafe-request-id": "req-42"})), + state="text", + questions={"q": {"type": "noul", "instructions": "?"}}, + ) + assert result.raw_http_response.status_code == 200 + assert result.raw_http_response.headers["x-typesafe-request-id"] == "req-42" + assert result.raw_http_response.json() == RESULT + + +def test_missing_raw_raises_on_access() -> None: + result = SystemOneResponse(model="test", usage=Usage(), answers={}) + with pytest.raises(TypeSafeError, match="raw HTTP response"): + _ = result.raw_http_response + + +async def test_missing_request_id_raises_on_access(clients: ClientFactory) -> None: + result = await system_one( + clients(lambda request: httpx2.Response(200, json=RESULT)), + state="text", + questions={"q": {"type": "noul", "instructions": "?"}}, + ) + with pytest.raises(TypeSafeError, match="request ID"): + _ = result.request_id + + +async def test_unknown_extra_fields_tolerated(clients: ClientFactory) -> None: + body = { + "model": "test", + "usage": {"input_tokens": 1, "output_tokens": 1, "reasoning_tokens": 9, "billing_units": 1}, + "answers": {"spam": {"type": "noul", "noul": 0.9, "explanation": "spammy"}}, + } + result = await system_one( + clients(lambda request: httpx2.Response(200, json=body)), + state="x", + questions={"q": {"type": "noul", "instructions": "?"}}, + ) + assert result.nouls["spam"].noul == 0.9 + assert not hasattr(result.usage, "billing_units") + assert msgspec.to_builtins(result.usage) == {"input_tokens": 1, "output_tokens": 1} + + +async def test_unknown_answer_type_ignored(clients: ClientFactory) -> None: + body = { + "model": "test", + "usage": {"input_tokens": 1, "output_tokens": 1}, + "answers": { + "spam": {"type": "noul", "noul": 0.9}, + "mystery": {"type": "aurora", "value": 3}, + }, + } + result = await system_one( + clients(lambda request: httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-9"})), + state="text", + questions={"q": {"type": "noul", "instructions": "?"}}, + ) + # A future answer type this SDK version does not model is skipped, not raised. + assert set(result.answers) == {"spam"} + assert result.nouls["spam"].noul == 0.9 + # The unknown answer is still available in the raw response. + assert result.raw_http_response.json()["answers"]["mystery"]["type"] == "aurora" + + +def test_response_preserves_nested_json() -> None: + original = ScoreAnswer( + score=0.0, + confidence=1.0, + legend={0: {"examples": ["a", {"note": None}]}}, + probabilities={0: 1.0}, + ) + result = SystemOneResponse( + model="test", + usage=Usage(input_tokens=1, output_tokens=1), + answers={"q": original}, + ) + assert not result.choices + answer = result.scores["q"] + assert answer is original + assert answer is result.answers["q"] + assert isinstance(result.usage, Usage) + assert answer.probabilities == {0: 1.0} + entry = answer.legend[0] + assert isinstance(entry, dict) + assert entry["examples"] == ["a", {"note": None}] + examples = entry["examples"] + assert isinstance(examples, list) + assert isinstance(examples[1], dict) + + exported = msgspec.to_builtins(answer) + assert exported == { + "type": "score", + "score": 0.0, + "confidence": 1.0, + "legend": {0: {"examples": ["a", {"note": None}]}}, + "probabilities": {0: 1.0}, + } + # to_builtins returns an independent deep copy: mutating it leaves the struct untouched. + exported["legend"][0]["examples"].append("new") + exported["probabilities"][0] = 0.5 + assert examples == ["a", {"note": None}] + assert answer.probabilities == {0: 1.0} + + +def test_answer_attributes_and_dictionary_types() -> None: + noul = NoulAnswer(noul=0.98) + choice = ChoiceAnswer(choice="billing", confidence=0.9, probabilities={"billing": 0.9, "support": 0.1}) + assert_type(noul.noul, float) + assert_type(choice.choice, str) + assert_type(choice.confidence, float) + assert_type(choice.probabilities, dict[str, float]) + assert msgspec.to_builtins(noul) == {"type": "noul", "noul": 0.98} + assert msgspec.to_builtins(choice) == { + "type": "choice", + "choice": "billing", + "confidence": 0.9, + "probabilities": {"billing": 0.9, "support": 0.1}, + } + + +@pytest.mark.parametrize( + "answer", + [ + NoulAnswer(noul=0.98), + ChoiceAnswer(choice="billing", confidence=1.0, probabilities={"billing": 1.0}), + ScoreAnswer(score=0.0, confidence=1.0, legend={0: "bad"}, probabilities={0: 1.0}), + ], +) +def test_answer_fields_are_frozen_and_slotted(answer: Answer) -> None: + assert not hasattr(answer, "__dict__") + with pytest.raises(AttributeError): + cast(Any, answer).type = "other" + + +@pytest.mark.parametrize("group", ["nouls", "choices", "scores"]) +def test_cached_groups_cannot_be_reassigned(group: str) -> None: + result = SystemOneResponse(model="test", usage=Usage(), answers={}) + with pytest.raises(AttributeError): + setattr(result, group, {}) + cached = getattr(result, group) + assert getattr(result, group) is cached + with pytest.raises(AttributeError): + setattr(result, group, {}) diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..2c0ad0f --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,524 @@ +import asyncio +from datetime import datetime, timezone +from email.utils import format_datetime +from types import SimpleNamespace + +import httpx2 +import msgspec +import pytest +from tenacity import RetryCallState + +from tests.conftest import ClientFactory +from tests.helpers import models, system_one +from tests.test_clients import RESULT +from typesafe_sdk import ( + AsyncTypeSafeClient, + Noul, + Questions, + RetryPolicy, + TypeSafeAPIConnectionError, + TypeSafeAPIError, + TypeSafeAPITimeoutError, + TypeSafeError, + TypeSafeRateLimitError, +) +from typesafe_sdk._core.errors import parse_retry_after + + +@pytest.mark.parametrize("timeout", [0, -1, float("inf"), float("nan")]) +def test_retry_policy_invalid_timeout(timeout: float) -> None: + with pytest.raises(TypeSafeError, match="timeout must be a positive, finite number of seconds"): + RetryPolicy(timeout=timeout) + + +@pytest.mark.parametrize("resource", ["models", "system_one"]) +@pytest.mark.parametrize( + "timeout,duration,delay,attempts", + [(None, 1.0, 0.5, 3), (30.0, 10.0, 5.0, 2), (2.5, 0.75, 0.5, 2), (2.0, 1.0, 0.0, 2), (1.0, 0.0, 1.0, 1), (1.0, 0.0, 60.0, 1)], +) +async def test_retry_policy_timeout_budget( + clients: ClientFactory, + monkeypatch: pytest.MonkeyPatch, + resource: str, + timeout: float | None, + duration: float, + delay: float, + attempts: int, +) -> None: + now = 100.0 + requests: list[httpx2.Request] = [] + delays: list[float] = [] + monkeypatch.setattr("tenacity.time", SimpleNamespace(monotonic=lambda: now)) + + def sleep(seconds: float) -> None: + nonlocal now + delays.append(seconds) + now += seconds + + async def sleep_async(seconds: float) -> None: + sleep(seconds) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal now + now += duration + requests.append(request) + return httpx2.Response(429, json={"message": f"attempt {len(requests)}"}, headers={"Retry-After": str(delay)}) + + client = clients(handler, retry=RetryPolicy(timeout=timeout)) + if isinstance(client, AsyncTypeSafeClient): + if resource == "models": + client.models._retry = client.models._retry.copy(sleep=sleep_async) + else: + client._retry = client._retry.copy(sleep=sleep_async) + elif resource == "models": + client.models._retry = client.models._retry.copy(sleep=sleep) + else: + client._retry = client._retry.copy(sleep=sleep) + + async def call() -> None: + if resource == "models": + await models(client) + else: + await system_one(client, state="x", questions={"q": Noul(instructions="?")}) + + for _ in range(2): # Each SDK call gets a fresh budget. + requests.clear() + delays.clear() + with pytest.raises(TypeSafeRateLimitError, match=f"attempt {attempts}$"): + await call() + assert len(requests) == attempts + assert delays == [delay] * (attempts - 1) + + +@pytest.mark.parametrize("resource", ["models", "system_one"]) +async def test_retry_policy_timeout_override(clients: ClientFactory, monkeypatch: pytest.MonkeyPatch, resource: str) -> None: + now = 100.0 + attempts = 0 + monkeypatch.setattr("tenacity.time", SimpleNamespace(monotonic=lambda: now)) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal now, attempts + # Each attempt advances the mock clock by 20s so the default 30.0s budget binds before the + # third attempt (elapsed 40s >= 30s), rather than only max_retries capping the loop. + now += 20.0 + attempts += 1 + return httpx2.Response(429, headers={"retry-after-ms": "0"}) + + client = clients(handler, retry=RetryPolicy()) + + async def call(retry: RetryPolicy | None) -> None: + if resource == "models": + await models(client, retry=retry) + else: + await system_one(client, state="x", questions={"q": Noul(instructions="?")}, retry=retry) + + for policy, expected in [(None, 2), (RetryPolicy(timeout=1.0), 1), (RetryPolicy(timeout=None), 3), (None, 2)]: + attempts = 0 + with pytest.raises(TypeSafeRateLimitError): + await call(policy) + assert attempts == expected + + +@pytest.mark.parametrize( + "status,attempts", [(408, 3), (429, 3), (500, 3), (503, 3), (599, 3), (400, 1), (401, 1), (403, 1), (404, 1), (409, 1), (422, 1), (302, 1)] +) +async def test_default_retry_statuses(clients: ClientFactory, status: int, attempts: int) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(status, json={"message": "failed"}, headers={"retry-after-ms": "0"}) + + with pytest.raises(TypeSafeAPIError) as caught: + await models(clients(handler, retries=True)) + assert caught.value.status == status + assert len(requests) == attempts + assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [None, "1", "2"][:attempts] + + +@pytest.mark.parametrize("kind", [httpx2.ConnectError, httpx2.ReadTimeout, httpx2.ReadError]) +async def test_connection_retry_recovers(clients: ClientFactory, kind: type[httpx2.RequestError]) -> None: + attempts = 0 + delays: list[float] = [] + + async def sleep(delay: float) -> None: + delays.append(delay) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise kind("failed", request=request) + else: + return httpx2.Response(200, json={"models": []}) + + client = clients(handler, retries=True) + if isinstance(client, AsyncTypeSafeClient): + client.models._retry = client.models._retry.copy(sleep=sleep) + else: + client.models._retry = client.models._retry.copy(sleep=delays.append) + assert await models(client) == () + assert attempts == 3 + assert 0.375 <= delays[0] <= 0.5 + assert 0.75 <= delays[1] <= 1.0 + + +@pytest.mark.parametrize( + "headers,delay", + [ + ({"Retry-After": "2"}, 2.0), + ({"retry-after-ms": "125"}, 0.125), + ({"retry-after-ms": "0", "Retry-After": "50"}, 0.0), + ({"Retry-After": "60"}, 60.0), + ], +) +async def test_server_delay_through_tenacity(clients: ClientFactory, headers: dict[str, str], delay: float) -> None: + attempts = 0 + delays: list[float] = [] + + async def sleep(seconds: float) -> None: + delays.append(seconds) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + return httpx2.Response(429, json={}, headers=headers) if attempts == 1 else httpx2.Response(200, json={"models": []}) + + client = clients(handler, retry=RetryPolicy(timeout=None)) + if isinstance(client, AsyncTypeSafeClient): + client.models._retry = client.models._retry.copy(sleep=sleep) + else: + client.models._retry = client.models._retry.copy(sleep=delays.append) + assert await models(client) == () + assert delays == [delay] + + +@pytest.mark.parametrize( + "headers,expected", + [ + ({}, None), + ({"Retry-After": "bad"}, None), + ({"Retry-After": "-1"}, None), + ({"retry-after-ms": "NaN", "Retry-After": "1.5"}, 1500), + ({"retry-after-ms": "-1", "Retry-After": "2"}, 2000), + ({"Retry-After": ""}, 0), + ({"retry-after-ms": "inf"}, None), + ({"retry-after-ms": "bad", "Retry-After": "2"}, 2000), + ], +) +def test_parse_retry_after(headers: dict[str, str], expected: float | None) -> None: + assert parse_retry_after(httpx2.Headers(headers)) == expected + + +def test_backoff_dates_cap_and_jitter(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("time.time", lambda: 1_000_000.0) + future = format_datetime(datetime.fromtimestamp(1_000_010, timezone.utc), usegmt=True) + past = format_datetime(datetime.fromtimestamp(999_990, timezone.utc), usegmt=True) + assert parse_retry_after(httpx2.Headers({"Retry-After": future})) == 10_000 + assert parse_retry_after(httpx2.Headers({"Retry-After": past})) == 0 + wait = RetryPolicy()._wait + state = RetryCallState(RetryPolicy()._build_tenacity(), None, (), {}) + monkeypatch.setattr("random.random", lambda: 0.0) + for attempt, expected in [(1, 0.5), (2, 1.0), (3, 2.0), (4, 4.0), (5, 5.0), (20, 5.0)]: + state.attempt_number = attempt + assert wait(state) == expected + monkeypatch.setattr("random.random", lambda: 1.0) + state.attempt_number = 1 + assert wait(state) == 0.375 + for header, expected in [({"Retry-After": "61"}, 61), ({"retry-after-ms": "60001"}, 60.001), ({"Retry-After": future}, 10)]: + error = TypeSafeRateLimitError(429, {}, httpx2.Headers(header)) + state.set_exception((type(error), error, None)) + assert wait(state) == expected # Server-requested delays are always honored, however long. + error = TypeSafeRateLimitError(429, {}, httpx2.Headers({"Retry-After": "bad"})) # Unparseable headers fall back to backoff. + state.set_exception((type(error), error, None)) + assert wait(state) == 0.375 + + +@pytest.mark.parametrize("client_attempts,call_attempts", [(1, 3), (3, 1)]) +async def test_system_one_retry_override(clients: ClientFactory, client_attempts: int, call_attempts: int) -> None: + requests: list[httpx2.Request] = [] + client_policy = RetryPolicy(max_retries=client_attempts - 1) + call_policy = RetryPolicy(max_retries=call_attempts - 1, http_statuses={409}) + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + status = 409 if request.headers["x-call"] == "override" else 429 + return httpx2.Response(status, json={"message": "failed"}, headers={"retry-after-ms": "0"}) + + client = clients(handler, retry=client_policy) + calls = [ + ("override", call_policy, call_attempts), + ("inherited", None, client_attempts), + ("override", call_policy, call_attempts), + ] + for name, retry, attempts in calls: + requests.clear() + with pytest.raises(TypeSafeAPIError): + await system_one( + client, + state="hello", + questions={"q": {"type": "noul", "instructions": "?"}}, + extra_headers={"x-call": name}, + retry=retry, + ) + assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [None, *(str(index) for index in range(1, attempts))] + + +async def test_async_concurrent_retry_state() -> None: + attempts: dict[str, list[str | None]] = {} + + async def handler(request: httpx2.Request) -> httpx2.Response: + key = request.headers["x-call"] + calls = attempts.setdefault(key, []) + calls.append(request.headers.get("x-typesafe-retry-count")) + await asyncio.sleep(0) + return httpx2.Response(429, headers={"retry-after-ms": "0"}) if len(calls) == 1 else httpx2.Response(200, json={"models": []}) + + async with AsyncTypeSafeClient(api_key="test", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) as client: + responses = await asyncio.gather(*(client.models.list(extra_headers={"x-call": str(index)}) for index in range(4))) + assert [response.models for response in responses] == [(), (), (), ()] + assert all(value == [None, "1"] for value in attempts.values()) + + +@pytest.mark.parametrize("raw", [False, True]) +@pytest.mark.parametrize("timeout", [2.0, httpx2.Timeout(3.0, connect=1.0, read=5.0)]) +async def test_system_one_retry_recovers_with_overrides( + clients: ClientFactory, + raw: bool, + timeout: float | httpx2.Timeout, +) -> None: + requests: list[httpx2.Request] = [] + delays: list[float] = [] + + async def sleep(delay: float) -> None: + delays.append(delay) + + questions: Questions = { + "q": {"type": "noul", "instructions": "?"} if raw else Noul(instructions="?"), + } + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if len(requests) == 1: + raise httpx2.ReadTimeout("timed out", request=request) + elif len(requests) == 2: + return httpx2.Response(429, json={"message": "slow down"}, headers={"retry-after-ms": "125"}) + return httpx2.Response(200, json=RESULT) + + headers = {"x-call": "override", "authorization": "must-not-win"} + client = clients(handler, model="client-model", timeout=7.0, headers={"x-default": "kept"}, retries=True) + if isinstance(client, AsyncTypeSafeClient): + client._retry = client._retry.copy(sleep=sleep) + else: + client._retry = client._retry.copy(sleep=delays.append) + result = await system_one( + client, + state={"document": "hello"}, + questions=questions, + model="call-model", + timeout=timeout, + extra_headers=headers, + ) + assert result.scores["quality"].score == 1.7 + assert result.choices["tone"].confidence == 0.9 + expected = msgspec.json.encode( + { + "state": {"document": "hello"}, + "model": "call-model", + "questions": {"q": {"type": "noul", "instructions": "?"}}, + } + ) + for request in requests: + assert request.content == expected + assert request.extensions["timeout"] == httpx2.Timeout(timeout).as_dict() + assert request.headers["authorization"] == "Bearer test-key" + assert request.headers["x-default"] == "kept" + assert request.headers["x-call"] == "override" + assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [None, "1", "2"] + assert 0.375 <= delays[0] <= 0.5 + assert delays[1] == 0.125 + assert headers == {"x-call": "override", "authorization": "must-not-win"} + + await system_one(client, state="next", questions=questions) + assert msgspec.json.decode(requests[-1].content)["model"] == "client-model" + assert requests[-1].extensions["timeout"] == httpx2.Timeout(7.0).as_dict() + assert "x-call" not in requests[-1].headers + assert "x-typesafe-retry-count" not in requests[-1].headers + + +async def test_concurrent_system_one_overrides() -> None: + attempts: dict[str, list[httpx2.Request]] = {} + + async def handler(request: httpx2.Request) -> httpx2.Response: + key = request.headers["x-call"] + calls = attempts.setdefault(key, []) + calls.append(request) + await asyncio.sleep(0) + return httpx2.Response(429, json={"message": "retry"}, headers={"retry-after-ms": "0"}) + + async with AsyncTypeSafeClient( + api_key="test", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + retry=RetryPolicy(max_retries=1), + timeout=9.0, + ) as client: + + async def call(name: str, retries: int | None, timeout: float) -> None: + with pytest.raises(TypeSafeRateLimitError): + await client.system_one( + state=name, + questions={"q": Noul(instructions="?")}, + model=name, + extra_headers={"x-call": name}, + timeout=timeout, + retry=None if retries is None else RetryPolicy(max_retries=retries - 1), + ) + + await asyncio.gather(call("one", 1, 1.0), call("three", 3, 3.0), call("default", None, 2.0)) + for name, count in (("one", 1), ("three", 3), ("default", 2)): + requests = attempts[name] + assert len(requests) == count + assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [ + None, + *(str(index) for index in range(1, count)), + ] + assert all(msgspec.json.decode(request.content)["model"] == name for request in requests) + assert all(msgspec.json.decode(request.content)["state"] == name for request in requests) + assert all(request.extensions["timeout"] == httpx2.Timeout(float(count)).as_dict() for request in requests) + + +@pytest.mark.parametrize("kind", [httpx2.ReadTimeout, httpx2.ConnectError]) +async def test_exhausted_transport_retry(clients: ClientFactory, kind: type[httpx2.RequestError]) -> None: + attempts = 0 + timeout = httpx2.Timeout(2.0, read=4.0) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + raise kind(f"attempt {attempts}", request=request) + + with pytest.raises(TypeSafeAPIConnectionError) as caught: + await system_one( + clients(handler), + state="x", + questions={"q": Noul(instructions="?")}, + retry=RetryPolicy(backoff_initial=0.001, backoff_max=0.001), + timeout=timeout, + ) + assert attempts == 3 + assert isinstance(caught.value.__cause__, kind) + assert str(caught.value.__cause__) == "attempt 3" + if kind is httpx2.ReadTimeout: + assert isinstance(caught.value, TypeSafeAPITimeoutError) + assert caught.value.timeout is timeout + else: + assert type(caught.value) is TypeSafeAPIConnectionError + + +async def test_exhausted_retry_preserves_final_http_error(clients: ClientFactory) -> None: + attempts = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + return httpx2.Response( + [429, 500, 503][attempts - 1], + json={"message": f"attempt {attempts}"}, + headers={"x-typesafe-request-id": f"request-{attempts}", "retry-after-ms": "0"}, + ) + + with pytest.raises(TypeSafeAPIError) as caught: + await system_one(clients(handler, retries=True), state="x", questions={"q": Noul(instructions="?")}) + assert attempts == 3 + assert caught.value.status == 503 + assert caught.value.body == {"message": "attempt 3"} + assert caught.value.request_id == "request-3" + assert str(caught.value) == "503 attempt 3" + + +async def test_cancel_pending_retry() -> None: + sleeping = asyncio.Event() + + async def sleep(delay: float) -> None: + sleeping.set() + await asyncio.Event().wait() + + async with AsyncTypeSafeClient( + api_key="test", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: httpx2.Response(429))), + ) as client: + client.models._retry = client.models._retry.copy(sleep=sleep) + task = asyncio.create_task(client.models.list()) + await asyncio.wait_for(sleeping.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.parametrize("max_retries,attempts", [(0, 1), (1, 2), (4, 5)]) +async def test_retry_policy_max_retries(clients: ClientFactory, max_retries: int, attempts: int) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(429, json={"message": "slow"}, headers={"retry-after-ms": "0"}) + + with pytest.raises(TypeSafeRateLimitError): + await models(clients(handler, retry=RetryPolicy(max_retries=max_retries))) + assert len(requests) == attempts + + +@pytest.mark.parametrize("status,attempts", [(409, 3), (500, 1)]) +async def test_retry_policy_custom_statuses(clients: ClientFactory, status: int, attempts: int) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(status, json={"message": "x"}, headers={"retry-after-ms": "0"}) + + with pytest.raises(TypeSafeAPIError): + await models(clients(handler, retry=RetryPolicy(http_statuses={409}))) + assert len(requests) == attempts + + +async def test_retry_policy_per_call_override(clients: ClientFactory) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(429, json={}, headers={"retry-after-ms": "0"}) + + client = clients(handler, retry=RetryPolicy(max_retries=2)) + with pytest.raises(TypeSafeRateLimitError): + await models(client, retry=RetryPolicy(max_retries=0)) + assert len(requests) == 1 + + +@pytest.mark.parametrize( + "policy", + [ + RetryPolicy(max_retries=1, predicate=lambda error: isinstance(error, TypeSafeAPIError) and error.status == 404), + RetryPolicy(max_retries=1, exceptions={TypeSafeAPIError}), + ], +) +async def test_retry_policy_exceptions_and_predicate(clients: ClientFactory, policy: RetryPolicy) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(404, json={"message": "gone"}, headers={"retry-after-ms": "0"}) + + with pytest.raises(TypeSafeAPIError): + await models(clients(handler, retry=policy)) + assert len(requests) == 2 # A 404 is not retried by default; the predicate and extra exceptions opt in. + + +def test_retry_policy_wait_options(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("random.random", lambda: 0.0) + error = TypeSafeRateLimitError(429, {}, httpx2.Headers({"Retry-After": "5"})) + state = RetryCallState(RetryPolicy()._build_tenacity(), None, (), {}) + state.set_exception((type(error), error, None)) + state.attempt_number = 1 + assert RetryPolicy()._wait(state) == 5.0 + assert RetryPolicy(respect_retry_after=False)._wait(state) == 0.5 + assert RetryPolicy(backoff_initial=0.2, respect_retry_after=False)._wait(state) == 0.2 diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..df3f324 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,96 @@ +from typing import get_args, get_origin, get_type_hints + +import httpx2 +import msgspec +import pytest + +from tests.conftest import ClientFactory +from tests.helpers import system_one +from typesafe_sdk import AsyncTypeSafeClient, Choice, JSONValue, Noul, Questions, Score, TypeSafeClient + + +def test_json_value_and_state_exclude_top_level_none() -> None: + assert type(None) not in get_args(JSONValue) + for client in (AsyncTypeSafeClient, TypeSafeClient): + state_type = get_type_hints(client.system_one)["state"] + assert type(None) not in get_args(state_type) + text_type, object_type, array_type = get_args(state_type) + assert text_type is str + assert get_origin(object_type) is dict + key_type, value_type = get_args(object_type) + assert key_type is str + assert type(None) in get_args(value_type) + assert get_origin(array_type) is list + assert type(None) in get_args(get_args(array_type)[0]) + + +@pytest.mark.parametrize("raw", [False, True]) +async def test_array_inputs(clients: ClientFactory, raw: bool) -> None: + state: list[JSONValue | None] = [{"message": "Classify"}, None] + instructions: list[JSONValue | None] = ["Read the message", {"context": None}] + description: list[JSONValue | None] = ["Example", None] + questions: Questions + if raw: + questions = { + "yes": {"type": "noul", "instructions": instructions, "criteria": {"true": description, "false": None}}, + "label": {"type": "choice", "instructions": instructions, "criteria": {"a": description, "b": None}}, + "rating": {"type": "score", "instructions": instructions, "criteria": {0: description}}, + } + else: + questions = { + "yes": Noul(instructions=instructions, criteria={"true": description, "false": None}), + "label": Choice(instructions=instructions, criteria={"a": description, "b": None}), + "rating": Score(instructions=instructions, criteria={0: description}), + } + + def handler(request: httpx2.Request) -> httpx2.Response: + body = msgspec.json.decode(request.content) + assert body["state"] == state + assert body["questions"] == { + "yes": {"type": "noul", "instructions": instructions, "criteria": {"true": description, "false": None}}, + "label": {"type": "choice", "instructions": instructions, "criteria": {"a": description, "b": None}}, + "rating": {"type": "score", "instructions": instructions, "criteria": [description]}, + } + return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}}) + + await system_one(clients(handler), state=state, questions=questions) + + +async def test_raw_optional_fields_preserve_explicit_null(clients: ClientFactory) -> None: + questions: Questions = { + "yes": {"type": "noul", "instructions": None, "criteria": None}, + "label": {"type": "choice", "instructions": None, "criteria": {"a": None}}, + "rating": {"type": "score", "instructions": None, "criteria": ["good"]}, + } + + def handler(request: httpx2.Request) -> httpx2.Response: + assert msgspec.json.decode(request.content)["questions"] == questions + return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}}) + + await system_one(clients(handler), state="x", questions=questions) + + +async def test_explicitly_nullable_json_values(clients: ClientFactory) -> None: + state: dict[str, JSONValue | None] = {"missing": None, "items": [None, {"nested": None}]} + instructions: dict[str, JSONValue | None] = {"text": "Classify", "extra": None} + + def handler(request: httpx2.Request) -> httpx2.Response: + body = msgspec.json.decode(request.content) + assert body["state"] == state + assert body["questions"] == { + "yes": {"type": "noul", "instructions": instructions, "criteria": {"true": {"extra": None}}}, + "label": {"type": "choice", "instructions": instructions, "criteria": {"a": None, "b": {"extra": None}}}, + "rating": {"type": "score", "instructions": instructions, "criteria": [{"extra": None}]}, + } + return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}}) + + client = clients(handler) + questions = { + "yes": Noul(instructions=instructions, criteria={"true": {"extra": None}}), + "label": Choice(instructions=instructions, criteria={"a": None, "b": {"extra": None}}), + "rating": Score(instructions=instructions, criteria=[{"extra": None}]), + } + if isinstance(client, AsyncTypeSafeClient): + await client.system_one(state, questions) + else: + client.system_one(state, questions) diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..e40a45b --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,20 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).with_name("typing") + + +@pytest.mark.parametrize("path", sorted(FIXTURES.rglob("*.py")), ids=lambda path: str(path.relative_to(FIXTURES))) +def test_public_typing(path: Path) -> None: + result = subprocess.run( # noqa: S603 - Fixed interpreter and arguments; paths are repository-owned fixtures. + [sys.executable, "-m", "pyrefly", "check", "--config", str(ROOT / "pyrefly.toml"), "--expectations", str(path)], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/typing/negative/async_client.py b/tests/typing/negative/async_client.py new file mode 100644 index 0000000..1bb6b74 --- /dev/null +++ b/tests/typing/negative/async_client.py @@ -0,0 +1,11 @@ +import httpx2 +from tenacity import Retrying + +from typesafe_sdk import AsyncTypeSafeClient + + +async def invalid_arguments(client: AsyncTypeSafeClient) -> None: + await client.system_one(None, {}) # E: Argument `None` is not assignable to parameter `state` + AsyncTypeSafeClient(api_key="test", retry=Retrying()) # E: Argument `Retrying` is not assignable to parameter `retry` + AsyncTypeSafeClient(api_key="test", http_client=httpx2.Client()) # E: Argument `Client` is not assignable to parameter `http_client` + await client.system_one("x", {}, retry=Retrying()) # E: Argument `Retrying` is not assignable to parameter `retry` diff --git a/tests/typing/negative/questions.py b/tests/typing/negative/questions.py new file mode 100644 index 0000000..925de16 --- /dev/null +++ b/tests/typing/negative/questions.py @@ -0,0 +1,13 @@ +from typesafe_sdk import ChoiceModel, NoulModel, Question, ScoreModel + +choice: ChoiceModel = {"type": "noul", "instructions": "?", "criteria": {"a": None}} # E: is not assignable to TypedDict key `type` +noul: NoulModel = {"type": "choice", "instructions": "?"} # E: is not assignable to TypedDict key `type` +score: ScoreModel = {"type": "choice", "instructions": "?", "criteria": ["a"]} # E: is not assignable to TypedDict key `type` + +missing_type: NoulModel = {"instructions": "?"} # E: Missing required key `type` +missing_choice_criteria: ChoiceModel = {"type": "choice"} # E: Missing required key `criteria` +missing_score_criteria: ScoreModel = {"type": "score"} # E: Missing required key `criteria` +bad_extra: NoulModel = {"type": "noul", "extra": object()} # E: is not assignable +bad_criteria: ChoiceModel = {"type": "choice", "criteria": ["a"]} # E: is not assignable +bad_question: Question = {"unrelated": "value"} # E: is not assignable +bad_question_type: Question = {"type": 123} # E: is not assignable diff --git a/tests/typing/negative/sync_client.py b/tests/typing/negative/sync_client.py new file mode 100644 index 0000000..abc697a --- /dev/null +++ b/tests/typing/negative/sync_client.py @@ -0,0 +1,11 @@ +import httpx2 +from tenacity import AsyncRetrying + +from typesafe_sdk import TypeSafeClient + + +def invalid_arguments(client: TypeSafeClient) -> None: + client.system_one(None, {}) # E: Argument `None` is not assignable to parameter `state` + TypeSafeClient(api_key="test", retry=AsyncRetrying()) # E: Argument `AsyncRetrying` is not assignable to parameter `retry` + TypeSafeClient(api_key="test", http_client=httpx2.AsyncClient()) # E: Argument `AsyncClient` is not assignable to parameter `http_client` + client.system_one("x", {}, retry=AsyncRetrying()) # E: Argument `AsyncRetrying` is not assignable to parameter `retry` diff --git a/tests/typing/negative/transport.py b/tests/typing/negative/transport.py new file mode 100644 index 0000000..8581c02 --- /dev/null +++ b/tests/typing/negative/transport.py @@ -0,0 +1,23 @@ +import httpx2 +from tenacity import AsyncRetrying, Retrying + +from typesafe_sdk import AsyncTypeSafeClient, SystemOneResponse, TypeSafeClient +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.endpoints import prepare_models +from typesafe_sdk._core.transport import send, send_async + + +def wrong_sync_response(config: Config, http_client: httpx2.Client, retry: Retrying) -> SystemOneResponse: + return send(http_client, retry, prepare_models(config, None, None)) # E: is not assignable to declared return type + + +async def wrong_async_response(config: Config, http_client: httpx2.AsyncClient, retry: AsyncRetrying) -> SystemOneResponse: + return await send_async(http_client, retry, prepare_models(config, None, None)) # E: is not assignable to declared return type + + +def wrong_sync_client_response(config: Config, client: TypeSafeClient) -> SystemOneResponse: + return client._request(prepare_models(config, None, None)) # E: is not assignable to declared return type + + +async def wrong_async_client_response(config: Config, client: AsyncTypeSafeClient) -> SystemOneResponse: + return await client._request(prepare_models(config, None, None)) # E: is not assignable to declared return type diff --git a/tests/typing/transport.py b/tests/typing/transport.py new file mode 100644 index 0000000..46c69b9 --- /dev/null +++ b/tests/typing/transport.py @@ -0,0 +1,30 @@ +import httpx2 +from tenacity import AsyncRetrying, Retrying +from typing_extensions import assert_type + +from typesafe_sdk import AsyncTypeSafeClient, ListModelsResponse, Noul, SystemOneResponse, TypeSafeClient +from typesafe_sdk._core.config import Config +from typesafe_sdk._core.endpoints import prepare_models, prepare_system_one +from typesafe_sdk._core.transport import Request, RequestState, send, send_async + + +def sync(config: Config, client: TypeSafeClient, http_client: httpx2.Client, retry: Retrying, response: httpx2.Response) -> None: + models = prepare_models(config, None, None) + system_one = prepare_system_one(config, "x", {"q": Noul(instructions="?")}, None, None, None, None) + assert_type(models, Request[ListModelsResponse]) + assert_type(system_one, Request[SystemOneResponse]) + assert_type(RequestState(models).parse(response), ListModelsResponse) + assert_type(RequestState(system_one).parse(response), SystemOneResponse) + assert_type(send(http_client, retry, models), ListModelsResponse) + assert_type(send(http_client, retry, system_one), SystemOneResponse) + assert_type(client._request(models), ListModelsResponse) + assert_type(client._request(system_one), SystemOneResponse) + + +async def asynchronous(config: Config, client: AsyncTypeSafeClient, http_client: httpx2.AsyncClient, retry: AsyncRetrying) -> None: + models = prepare_models(config, None, None) + system_one = prepare_system_one(config, "x", {"q": Noul(instructions="?")}, None, None, None, None) + assert_type(await send_async(http_client, retry, models), ListModelsResponse) + assert_type(await send_async(http_client, retry, system_one), SystemOneResponse) + assert_type(await client._request(models), ListModelsResponse) + assert_type(await client._request(system_one), SystemOneResponse) diff --git a/tests/typing/valid.py b/tests/typing/valid.py new file mode 100644 index 0000000..28c6a50 --- /dev/null +++ b/tests/typing/valid.py @@ -0,0 +1,30 @@ +from collections.abc import Mapping + +import httpx2 +from typing_extensions import assert_type + +from typesafe_sdk import AsyncTypeSafeClient, Choice, ChoiceModel, ListModelsResponse, RetryPolicy, SystemOneResponse, TypeSafeClient + + +def sync(client: TypeSafeClient) -> None: + objects: dict[str, Choice] = {"q": Choice(instructions="?", criteria={"a": None})} + data: dict[str, ChoiceModel] = {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}} + assert_type(client.system_one({"nullable": None}, objects, retry=RetryPolicy(), timeout=httpx2.Timeout(None)), SystemOneResponse) + assert_type(client.system_one("x", data, model="test", extra_headers={"x-call": "test"}), SystemOneResponse) + assert_type( + client.system_one("x", {"q": {"type": "noul", "instructions": "?", "weight": 2}}, retry=RetryPolicy(max_retries=1)), SystemOneResponse + ) + assert_type(client.system_one("x", data, extra_body={"beam_width": 4, "nullable": None}), SystemOneResponse) + assert_type(client.models.list(retry=RetryPolicy(), timeout=2.0, extra_headers={"x-call": "test"}), ListModelsResponse) + TypeSafeClient(retry=RetryPolicy(max_retries=1)) + + +async def asynchronous(client: AsyncTypeSafeClient) -> None: + objects: Mapping[str, Choice] = {"q": Choice(instructions="?", criteria={"a": None})} + data: dict[str, ChoiceModel] = {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}} + assert_type(await client.system_one({"nullable": None}, objects, retry=RetryPolicy(), timeout=2.0), SystemOneResponse) + assert_type(await client.system_one("x", data, model="test", extra_headers={"x-call": "test"}), SystemOneResponse) + assert_type(await client.system_one("x", {"q": {"type": "noul", "instructions": "?", "weight": 2}}, retry=RetryPolicy()), SystemOneResponse) + assert_type(await client.system_one("x", data, extra_body={"beam_width": 4, "nullable": None}), SystemOneResponse) + assert_type(await client.models.list(retry=RetryPolicy(), timeout=2.0, extra_headers={"x-call": "test"}), ListModelsResponse) + AsyncTypeSafeClient(retry=RetryPolicy(max_retries=1)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cef08b9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1391 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "datamodel-code-generator" +version = "0.79.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/98/4b35f12e551d078e4297422e98cbc679f4792475bcf7da2e008308695d97/datamodel_code_generator-0.79.0.tar.gz", hash = "sha256:d1e4297c946cccb9c2f7f662167b5876d5554ce3884ac145042256af08f23924", size = 2661584, upload-time = "2026-09-10T17:56:30.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/62/c4ab81535f7fd6d46040908c535248ef96fa53d90c7379913256ddcd3f6c/datamodel_code_generator-0.79.0-py3-none-any.whl", hash = "sha256:0aa827080096844a56135eb990e69d622e79f74bad2e5f006ce797bd74477d1f", size = 695513, upload-time = "2026-09-10T17:56:27.845Z" }, +] + +[[package]] +name = "deepmerge" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "genson" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.62" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/db/3ca813cbacb23ab6fe46ff38a9b5ef8e73e970c8051f2ce903aacafe0446/gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af", size = 231728, upload-time = "2026-09-07T02:57:21.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, +] + +[[package]] +name = "griffelib" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/65/0db919b7c91bf033b42d04f15ff16a56f4edd6afb26e84d448359660ece3/mkdocstrings_python-2.0.8.tar.gz", hash = "sha256:34545b54681cf0b212aab95394d37b0f5326a93b102f6ad1df3d965c79384c9d", size = 201771, upload-time = "2026-08-31T20:16:06.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/b7/a5a854d4e44853f26decc0c5e0d68df052399f3da344a313da4178f00816/mkdocstrings_python-2.0.8-py3-none-any.whl", hash = "sha256:e555d9ef53b0febdeae4dd603144d4f86ec90fef672276645ea3bb61232c3d24", size = 105443, upload-time = "2026-08-31T20:16:05.25Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, + { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/17/2db4b414de89659144488e0d9c6c0bf0c8395841dc12d81d0532cc6ef310/pymdown_extensions-11.0.2.tar.gz", hash = "sha256:9506fcbe66fa355a775b768084334238dd6805020ac4b92bea0c0dda6f8f223d", size = 855419, upload-time = "2026-08-22T19:28:47.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/43/9f45ec4d14e596efc32c925a78104934790438b0c0628b70d741016734ad/pymdown_extensions-11.0.2-py3-none-any.whl", hash = "sha256:259910762019732caa1dfd76f3faa62c59f191d46573e80bcb1d13c0f675bbe5", size = 269929, upload-time = "2026-08-22T19:28:45.389Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyrefly" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, + { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, + { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, + { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "sybil" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/46/bae21847b8d761ddd6ede1811d32818342dbd482c32a2a5805c28d9b2f18/sybil-9.3.0.tar.gz", hash = "sha256:847d1d17b8a857c4bb3f8471b4a57b8affa939a60fbf507e70aa72ad79097c05", size = 89078, upload-time = "2025-12-02T09:29:09.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/08/cd3cf2a82570748cfb3142e795044197deff81ad3b70a0b9a9c22331e70a/sybil-9.3.0-py3-none-any.whl", hash = "sha256:0b108b980ab9fac774953042b07fcb5858aa19a38404d0cb42c30c93423ac0c1", size = 39286, upload-time = "2025-12-02T09:29:08.417Z" }, +] + +[[package]] +name = "sybil" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/50135b55ba14509654b2f624eaf7e318d654182ebe9f712fb74e98e418d0/sybil-10.1.0.tar.gz", hash = "sha256:062249c8886a0ab19e45d1c3afd5631ec806e7a95cf5153c96560f5e47756cbd", size = 82376, upload-time = "2026-06-13T09:40:44.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/fe/4094754188b52f8333ba24b377192918936597769a574a9dd8446f69e1f1/sybil-10.1.0-py3-none-any.whl", hash = "sha256:b3015f7e0ca3fe197ae67117c440710b62ea500078d4414ebd0ab804d12c9897", size = 40930, upload-time = "2026-06-13T09:40:43.139Z" }, +] + +[[package]] +name = "syrupy" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/3c797c76994dc817582abdda90ee31867a9883567b0add47dbc0ccb12ce4/syrupy-6.0.0.tar.gz", hash = "sha256:bf4f5f662a7b23a9e5711bd5597e301cd09c00f31be5fa4095f79da7b0585f3d", size = 99435, upload-time = "2026-08-22T20:48:39.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ff/4d8ae6d6070ea73ee9d466701584a75db9586ce09de98f8f1b3276efad5f/syrupy-6.0.0-py3-none-any.whl", hash = "sha256:3efa16d767bd8614413cc20057552e4aec14428c6e5ea788c1f7e03047e8a5e4", size = 58047, upload-time = "2026-08-22T20:48:38.213Z" }, +] + +[[package]] +name = "tach" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitpython" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "prompt-toolkit" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/54/8bae607899ba9fd7abd9895215f2e5dc789a73529f8afeb0a10d2ee71500/tach-0.35.0.tar.gz", hash = "sha256:db8594820685f8ce316ef45d4dfa1a8ed39ce97dd873aa5006800d2668c3b3bb", size = 731143, upload-time = "2026-05-12T12:48:58.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/65/7ec3ac15167d9a2def25ed5cc6699348a940d7d24408d0c0b3e9bf14f1c3/tach-0.35.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:83b43975bcd23ddca2de388891d9f2cce7de3775682ac0a7857981adc4454787", size = 4042721, upload-time = "2026-05-12T12:48:52.149Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b6/e43617eaf57cd2f8a27935c87256eab63b8505e1c7b7a3e1947f2a612f88/tach-0.35.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:f14a3943783fa3285030468958718a0696d908d50a5c5119e2b42dae37e32114", size = 3910668, upload-time = "2026-05-12T12:48:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/936963142c9af18e7a59909960bf6d4645e82190471e162c28ba80dd2475/tach-0.35.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0109591f1be3d0e323769d495b1e303ee41305bf03e9143328000a40eee7059", size = 4262505, upload-time = "2026-05-12T12:48:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/87/0e/575f119447647e2321fc71bacf3909450b743fcbaf5015f11037bd304fee/tach-0.35.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:272234f3ff9f6cef54dffb5175a83313938461e37617c1fe18bbbaa2cc52179c", size = 4156922, upload-time = "2026-05-12T12:48:47.828Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/d235f6ba6be79c8c4490ee1469a73e2325d1f8f380c29954c4103b6aff09/tach-0.35.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:236d2113e4920e83fa60fd814d09a7fc9cbab87fd60a0a79d5afc9dd60a81102", size = 4576596, upload-time = "2026-05-12T12:49:02.238Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e5/254462c90ec22cd1f2f8f5dc27f4b8bef62940eb3775565366d3fb6cd563/tach-0.35.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c65273272f842c1490c2086279296a250a3eeb40b01c02a252b0c8021c52441", size = 5202902, upload-time = "2026-05-12T12:48:53.869Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/aeb321f3752522198a5e8a2f8b0e88cf36f54c108938ec50a845fe19290c/tach-0.35.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afa96c12355acf2a00dc27d3c39ccbca5516fa954640051d9dab9fd47a373d6d", size = 4284724, upload-time = "2026-05-12T12:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/37/07/65d158d670a3106f3b327e53187fdf812a2894a3286407154f802ade6874/tach-0.35.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162677645f6a28b0fd91d5d33334b1e92ac40c8085540f40bf072fc8ab6a2b0c", size = 4530463, upload-time = "2026-05-12T12:49:04.743Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/7fc5ee5e9521ab6d75c0fa9affad4f324bf70f0554c47251cba599a30b5e/tach-0.35.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e9940ec5f07200e349fb68d85939f72b659b96644c28d2f3d4e97d290ec85f95", size = 4437612, upload-time = "2026-05-12T12:48:46.409Z" }, + { url = "https://files.pythonhosted.org/packages/ea/06/5dd9e47839e9d5b202bf1f9d9d526940020f561d461607498938d2c55a5f/tach-0.35.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4162f07b9ca0364eb4f05530b2b6a66b6c6544fae8752e4378458f604346f800", size = 4432234, upload-time = "2026-05-12T12:48:49.178Z" }, + { url = "https://files.pythonhosted.org/packages/81/87/267e1ea74dc4bacce03d301991b39da58211f636325b1fed7bd83b5e9c40/tach-0.35.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:2c2e9fc654a7a250f3698b26872c89de7b42f649736e3c322cb91edcc749cd55", size = 4587446, upload-time = "2026-05-12T12:49:03.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c3/0859a8f4f85b167174a9173df79bc0f15b93908d69537a737f1c44e2d8b9/tach-0.35.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:e73aebad3af4aa99c20f72d54f270e40eac8681b5fd3bb439333936bfba5c892", size = 5330778, upload-time = "2026-05-12T12:48:55.096Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/e1842c063d0ac7be804797fab569ac782ef8a9fa349092c71125bc628e4d/tach-0.35.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e9275b65ff31e7ff9842b3bd7417a393115dbadd1fa8c3d953ae330cff7fbefb", size = 4565068, upload-time = "2026-05-12T12:49:06.024Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/054784261781dbcb8ac663a5d09af6c7996f5bde5230282b4c062dcf1f1f/tach-0.35.0-cp37-abi3-win32.whl", hash = "sha256:72dd6553d9fdd1e72fbd8e5b71892a463e8cd70b3ff6377b4f682aab100937d3", size = 3378975, upload-time = "2026-05-12T12:49:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1d/5dddf88fa9433bf4fcc76afad18e49d8e50e24f1f40c981c286edccd6c2d/tach-0.35.0-cp37-abi3-win_amd64.whl", hash = "sha256:746271a5313c348e00c1b85927260e3e1a75351d86b2da1746e85c5dd77051e6", size = 3671788, upload-time = "2026-05-12T12:48:59.619Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + +[[package]] +name = "typesafe-sdk" +version = "0.5.7" +source = { editable = "." } +dependencies = [ + { name = "httpx2" }, + { name = "msgspec" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +codegen = [ + { name = "datamodel-code-generator" }, +] +dev = [ + { name = "gitpython" }, + { name = "pyrefly" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "sybil", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sybil", version = "10.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "syrupy" }, + { name = "tach" }, +] +docs = [ + { name = "mkdocstrings", extra = ["python"] }, + { name = "ruff" }, + { name = "zensical" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx2", specifier = ">=2.0.0" }, + { name = "msgspec", specifier = ">=0.21.1" }, + { name = "tenacity", specifier = ">=9.0.0" }, + { name = "typing-extensions", specifier = ">=4.13.0" }, +] + +[package.metadata.requires-dev] +codegen = [{ name = "datamodel-code-generator", specifier = ">=0.79.0" }] +dev = [ + { name = "gitpython", specifier = ">=3.1.62" }, + { name = "pyrefly", specifier = ">=1.2.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "ruff", specifier = ">=0.16.6" }, + { name = "sybil", specifier = ">=9.3.0" }, + { name = "syrupy", specifier = ">=6.0.0" }, + { name = "tach", specifier = ">=0.35.0" }, +] +docs = [ + { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.6" }, + { name = "ruff", specifier = ">=0.16.6" }, + { name = "zensical", specifier = ">=0.0.60" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +] + +[[package]] +name = "zensical" +version = "0.0.60" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/49/4b0ae6d31ef1371a5a33e23e443dbc46d66e039f7dcc4337a251b3b586e7/zensical-0.0.60.tar.gz", hash = "sha256:f83cf6afb1388c3ebd377df0309bafbbdf39b6aff55276a75b161e817e72dd6c", size = 4122761, upload-time = "2026-09-08T13:56:34.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/93/122b6cf3db52ac9375b570d0afcedb69ab624a84ef1e86da30d3464cf299/zensical-0.0.60-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:61348cf7491c6e0d7496828763aea58c3bf43c93153e49860e68d9b9897c3ff0", size = 14202456, upload-time = "2026-09-08T13:56:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/dd/53/ca98938bf77fd6931fa1b56081d3c7c93ebac7c90f2ce761d5d33fddb3f8/zensical-0.0.60-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2655d7f3d5ce3522791db6fe063876803edf8f119e5a24670649c3943d28f67e", size = 13951282, upload-time = "2026-09-08T13:56:14.42Z" }, + { url = "https://files.pythonhosted.org/packages/72/53/bdac9d90381c6c922bfe2589690d894b1a7c867bb677be6c6fc68f39b2ab/zensical-0.0.60-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08eb76adb4d6cd902633f3db3aeb78e38693b91de7efa1a02d37cab8f03e7d1f", size = 14222451, upload-time = "2026-09-08T13:56:16.858Z" }, + { url = "https://files.pythonhosted.org/packages/62/6f/793dd1dc2905b199abac770e9c534a901c69d4a887148e2ba8deb44278ab/zensical-0.0.60-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6bce6a25c9eee12be6c20a0b02d84c515dea37c41eabffd54b7c25ba53d219f6", size = 14244005, upload-time = "2026-09-08T13:56:19.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/79d0be0cae79537f203cbdba78bbe1a286809b872d6b3bc3bc240df94c4d/zensical-0.0.60-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65aec11eaff5fa12bc548a16c8879856c66f549a2f8b1be5375d08e085899d7f", size = 14500231, upload-time = "2026-09-08T13:56:22.291Z" }, + { url = "https://files.pythonhosted.org/packages/11/ef/e8a067a968043c02ac18dc98ff28134f4bbcc89b28ebf79b5dc486b2e5e6/zensical-0.0.60-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e41c1632e69a33d40dda2cb988478801c3fe9d8c9b415ef124b3e9fa3b541aa7", size = 14397275, upload-time = "2026-09-08T13:56:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/a4/04/44c89ed5e6063685406e19a038d537d1a4e23827576855253aa6f325fcf0/zensical-0.0.60-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:db08984a78e3efccf44a94a9018b74822bacbe8d46acf6dbe1d70eba71dbf901", size = 14729268, upload-time = "2026-09-08T13:56:27.39Z" }, + { url = "https://files.pythonhosted.org/packages/a4/21/ac185d4b1431943d908bd05c4fe08bec31be897f762dc992850d7aa566b0/zensical-0.0.60-cp310-abi3-win_amd64.whl", hash = "sha256:4013562c15370b4a5c49e9b429b072008aef1cc99b05c8836c483fb57e9ef3aa", size = 14513117, upload-time = "2026-09-08T13:56:30.033Z" }, + { url = "https://files.pythonhosted.org/packages/10/c4/be9b56da4f838da24b108e7ae17b93d6d6a38d84fdb5f1c1fd700fd8c967/zensical-0.0.60-cp310-abi3-win_arm64.whl", hash = "sha256:a627da582bd3af0c6f0e750750a51911435baa5b82dc73726b1e7dcba2713426", size = 14223748, upload-time = "2026-09-08T13:56:32.59Z" }, +]