diff --git a/sdk/python/openmeter/.flake8 b/sdk/python/openmeter/.flake8 new file mode 100644 index 0000000000..6624b6ad71 --- /dev/null +++ b/sdk/python/openmeter/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 100 +extend-exclude = .venv,build,dist + diff --git a/sdk/python/openmeter/.gitignore b/sdk/python/openmeter/.gitignore new file mode 100644 index 0000000000..853d5b8650 --- /dev/null +++ b/sdk/python/openmeter/.gitignore @@ -0,0 +1,7 @@ +/.venv/ +/.ruff_cache/ +/build/ +/dist/ +*.egg-info/ +__pycache__/ +*.py[cod] diff --git a/sdk/python/openmeter/MANIFEST.in b/sdk/python/openmeter/MANIFEST.in new file mode 100644 index 0000000000..d4aef524fa --- /dev/null +++ b/sdk/python/openmeter/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include examples *.py + diff --git a/sdk/python/openmeter/README.md b/sdk/python/openmeter/README.md new file mode 100644 index 0000000000..999d26858c --- /dev/null +++ b/sdk/python/openmeter/README.md @@ -0,0 +1,154 @@ +# OpenMeter Python SDK (v3 baseline) + +An idiomatic, hand-written Python SDK for the OpenMeter v3 API. This baseline +covers the same endpoint groups as the Go SDK baseline in PR #4644: meters and +plan add-ons. It is intentionally not a complete OpenMeter client. + +## Requirements + +- Python 3.9 or newer +- Pydantic v2 +- httpx (async client only, installed through the `async` extra) + +The sync `Client` uses only the Python standard library (`urllib`) for HTTP. +The async `AsyncClient` uses `httpx.AsyncClient` for true async I/O — no +thread pool involved. Pydantic is the only required runtime dependency. + +## Install + +```bash +python -m pip install ./sdk/python/openmeter +``` + +Install the async client dependencies with: + +```bash +python -m pip install "./sdk/python/openmeter[async]" +``` + +## Client setup + +The base URL must include the deployment's v3 prefix: + +| Deployment | Base URL | +| --- | --- | +| Local | `http://127.0.0.1:8888/api/v3` | +| OpenMeter Cloud | `https://openmeter.cloud/api/v3` | +| Kong Konnect | `https://.api.konghq.com/v3` | + +```python +from openmeter import Client + +with Client( + "https://openmeter.cloud/api/v3", + token="om_...", +) as client: + page = client.meters.list() + print(page.data) +``` + +The async client has the same resource groups and method names: + +```python +import asyncio + +from openmeter import AsyncClient + + +async def main() -> None: + async with AsyncClient( + "https://openmeter.cloud/api/v3", + token="om_...", + ) as client: + async for meter in client.meters.list_all(): + print(meter.key) + + +asyncio.run(main()) +``` + +Complete runnable snippets live in [`examples/`](examples/). Public classes and +methods include docstrings, so `help(openmeter.Client)` and editor tooltips also +serve as API documentation. + +## Pydantic models + +Request models validate v3 constraints before sending a request: + +```python +from openmeter import CreateMeterRequest + +request = CreateMeterRequest( + name="Tokens Total", + key="tokens_total", + aggregation="sum", + event_type="prompt", + value_property="$.tokens", +) +``` + +Unknown response fields are ignored to preserve additive API compatibility. +Response enum-like fields remain plain strings, allowing a newer server value to +round-trip through an older SDK. Request enum values are validated against the +current v3 contract. + +## Implemented endpoints + +| Resource method | HTTP endpoint | +| --- | --- | +| `meters.create` / `meters.list` / `meters.list_all` | `POST`, `GET /openmeter/meters` | +| `meters.get` / `meters.update` / `meters.delete` | `GET`, `PUT`, `DELETE /openmeter/meters/{meterId}` | +| `meters.query` / `meters.query_csv` / `meters.query_csv_stream` | `POST /openmeter/meters/{meterId}/query` | +| `plan_addons.create` / `plan_addons.list` / `plan_addons.list_all` | `POST`, `GET /openmeter/plans/{planId}/addons` | +| `plan_addons.get` / `plan_addons.update` / `plan_addons.delete` | `GET`, `PUT`, `DELETE /openmeter/plans/{planId}/addons/{planAddonId}` | + +`list_all` is a lazy iterator on the sync client and an async iterator on the +async client. Buffered JSON and CSV bodies are capped at 10 MiB. Use +`query_csv_stream` for large CSV exports and close it with `with` or `async with`. +Plan-add-on operations and meter update/query operations are marked unstable in +the current v3 specification and can evolve before they become stable. + +## Errors and transport + +Non-2xx responses raise `APIError`, exposing `status_code`, `title`, `detail`, +`type`, `instance`, parsed invalid parameters, and the capped raw response body. +Transport failures raise `TransportError`; client-side model failures use +Pydantic's `ValidationError`. + +The sync client accepts an optional `urllib.request.OpenerDirector` to customize +proxy, TLS, and authentication behavior without adding an HTTP dependency. The +async client accepts an optional preconfigured `httpx.AsyncClient` for the same +purpose. It follows standard proxy environment variables by default; pass +`trust_env=False` to ignore them. Pass `timeout=None` only when the caller +supplies another transport-level bound. + +GET, PUT, DELETE, and read-only meter query requests are retried up to twice +with exponential backoff on connection failures or a 502/503/504 response. +Resource-creating POST requests are never retried, since a retry could duplicate +the resource or its notification/webhook side effects. + +## Development + +The package uses [uv](https://docs.astral.sh/uv/) for the contributor environment +and lockfile. From `sdk/python/openmeter`, install the development dependencies +with the supported Python floor: + +```bash +uv sync --python 3.9 +``` + +Run linting, formatting, type checking, tests, and the package build through the +locked environment: + +```bash +uv run flake8 src tests examples +uv run ruff check . +uv run ruff format --check . +uv run pyright +uv run python -m unittest discover -s tests -v +uv build +``` + +Tests use `unittest` and a local standard-library HTTP server. Package consumers +do not need uv and can continue installing the SDK with pip or another Python +package manager. diff --git a/sdk/python/openmeter/examples/async.py b/sdk/python/openmeter/examples/async.py new file mode 100644 index 0000000000..57ad61f9d7 --- /dev/null +++ b/sdk/python/openmeter/examples/async.py @@ -0,0 +1,21 @@ +"""List meters with the asynchronous v3 client.""" + +import asyncio +import os + +from openmeter import AsyncClient + + +async def main() -> None: + """Run the asynchronous example using environment-based configuration.""" + + base_url = os.getenv("OPENMETER_BASE_URL", "http://127.0.0.1:8888/api/v3") + token = os.getenv("OPENMETER_TOKEN") + + async with AsyncClient(base_url, token=token) as client: + async for meter in client.meters.list_all(): + print(meter.key, meter.aggregation) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/openmeter/examples/sync.py b/sdk/python/openmeter/examples/sync.py new file mode 100644 index 0000000000..cc4e66916a --- /dev/null +++ b/sdk/python/openmeter/examples/sync.py @@ -0,0 +1,25 @@ +"""List and query meters with the synchronous v3 client.""" + +import os + +from openmeter import Client, MeterQueryRequest + + +def main() -> None: + """Run the synchronous example using environment-based configuration.""" + + base_url = os.getenv("OPENMETER_BASE_URL", "http://127.0.0.1:8888/api/v3") + token = os.getenv("OPENMETER_TOKEN") + + with Client(base_url, token=token) as client: + for meter in client.meters.list_all(): + print(meter.key, meter.aggregation) + + meter_id = os.getenv("OPENMETER_METER_ID") + if meter_id: + result = client.meters.query(meter_id, MeterQueryRequest()) + print(result.data) + + +if __name__ == "__main__": + main() diff --git a/sdk/python/openmeter/pyproject.toml b/sdk/python/openmeter/pyproject.toml new file mode 100644 index 0000000000..4a76ca7613 --- /dev/null +++ b/sdk/python/openmeter/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "openmeter" +version = "0.1.0" +description = "Idiomatic Python SDK for the OpenMeter v3 API" +readme = "README.md" +requires-python = ">=3.9" +license = "Apache-2.0" +authors = [ + { name = "OpenMeter", email = "info@openmeter.io" }, +] +keywords = ["billing", "metering", "openmeter", "sdk", "usage"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "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", + "Typing :: Typed", +] +dependencies = [ + "pydantic>=2.10,<3", +] + +[project.optional-dependencies] +async = [ + "httpx>=0.28.1,<1", +] + +[dependency-groups] +dev = [ + "build>=1.2,<2", + "flake8>=7,<8", + "httpx>=0.28.1,<1", + "pyright>=1.1.400,<2", + "ruff>=0.14,<1", +] + +[project.urls] +Documentation = "https://openmeter.io/docs" +Issues = "https://github.com/openmeterio/openmeter/issues" +Repository = "https://github.com/openmeterio/openmeter" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] +# Optional/Union spellings keep annotations unambiguous for the Python 3.9 floor. +ignore = ["UP006", "UP007", "UP035", "UP045"] + +[tool.pyright] +include = ["src", "examples"] +pythonVersion = "3.9" +typeCheckingMode = "standard" diff --git a/sdk/python/openmeter/src/openmeter/__init__.py b/sdk/python/openmeter/src/openmeter/__init__.py new file mode 100644 index 0000000000..19dcdf5486 --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/__init__.py @@ -0,0 +1,112 @@ +"""OpenMeter Python SDK for the v3 API baseline.""" + +from typing import TYPE_CHECKING, Any + +from ._version import __version__ +from .client import Client, Meters, PlanAddons +from .errors import ( + APIError, + InvalidIDError, + OpenMeterError, + PaginationError, + ResponseTooLargeError, + TransportError, +) +from .models import ( + AddonReference, + CreateMeterRequest, + CreatePlanAddonRequest, + InvalidParameter, + Meter, + MeterAggregation, + MeterFilter, + MeterListParams, + MeterPage, + MeterQueryFilters, + MeterQueryGranularity, + MeterQueryRequest, + MeterQueryResult, + MeterQueryRow, + PageMeta, + PageParams, + PaginatedMeta, + PlanAddon, + PlanAddonListParams, + PlanAddonPage, + ProblemDetails, + ProductCatalogValidationError, + QueryFilterString, + QueryFilterStringMapItem, + StringFilter, + UpdateMeterRequest, + UpsertPlanAddonRequest, +) + +if TYPE_CHECKING: + from .aio import AsyncByteStream, AsyncClient + +_ASYNC_EXPORTS = frozenset({"AsyncByteStream", "AsyncClient"}) + + +def __getattr__(name: str) -> Any: + if name not in _ASYNC_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + try: + from .aio import AsyncByteStream, AsyncClient + except ModuleNotFoundError as error: + if error.name != "httpx": + raise + raise ImportError( + "async support requires the 'async' extra: pip install 'openmeter[async]'" + ) from error + + exports = { + "AsyncByteStream": AsyncByteStream, + "AsyncClient": AsyncClient, + } + globals().update(exports) + return exports[name] + + +__all__ = [ + "APIError", + "AddonReference", + "AsyncByteStream", + "AsyncClient", + "Client", + "CreateMeterRequest", + "CreatePlanAddonRequest", + "InvalidIDError", + "InvalidParameter", + "Meter", + "MeterAggregation", + "MeterFilter", + "MeterListParams", + "MeterPage", + "MeterQueryFilters", + "MeterQueryGranularity", + "MeterQueryRequest", + "MeterQueryResult", + "MeterQueryRow", + "Meters", + "OpenMeterError", + "PageMeta", + "PageParams", + "PaginatedMeta", + "PaginationError", + "PlanAddon", + "PlanAddonListParams", + "PlanAddonPage", + "PlanAddons", + "ProblemDetails", + "ProductCatalogValidationError", + "QueryFilterString", + "QueryFilterStringMapItem", + "ResponseTooLargeError", + "StringFilter", + "TransportError", + "UpdateMeterRequest", + "UpsertPlanAddonRequest", + "__version__", +] diff --git a/sdk/python/openmeter/src/openmeter/_transport.py b/sdk/python/openmeter/src/openmeter/_transport.py new file mode 100644 index 0000000000..5ff1bb3063 --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/_transport.py @@ -0,0 +1,182 @@ +"""Standard-library HTTP transport used by the synchronous client.""" + +from __future__ import annotations + +import time +from typing import Any, BinaryIO, Optional, Type, TypeVar +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, OpenerDirector, Request, build_opener + +from pydantic import BaseModel, ValidationError + +from ._version import __version__ +from .errors import APIError, ResponseTooLargeError, TransportError +from .models import ProblemDetails + +_BUFFER_LIMIT = 10 * 1024 * 1024 +_ERROR_LIMIT = 1024 * 1024 +_USER_AGENT = f"openmeter-python/{__version__}" + +# GET/PUT/DELETE and read-only meter queries are safe to retry. Other POST +# requests create resources and could double-create when retried. +_RETRYABLE_METHODS = frozenset({"GET", "PUT", "DELETE"}) +_RETRYABLE_POST_PATH_SUFFIX = "/query" +_RETRYABLE_STATUS_CODES = frozenset({502, 503, 504}) +_MAX_RETRIES = 2 +_RETRY_BACKOFF_SECONDS = 0.5 + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +class _NoRedirectHandler(HTTPRedirectHandler): + def redirect_request(self, *_: Any, **__: Any) -> None: + return None + + +class _Transport: + def __init__( + self, + base_url: str, + *, + token: Optional[str], + timeout: Optional[float], + opener: Optional[OpenerDirector], + ) -> None: + parsed = urlsplit(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("base_url must be an absolute HTTP or HTTPS URL") + if parsed.query or parsed.fragment: + raise ValueError("base_url must not contain a query string or fragment") + if timeout is not None and timeout <= 0: + raise ValueError("timeout must be positive or None") + + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout = timeout + self.opener = opener or build_opener(_NoRedirectHandler()) + + def request_json( + self, + method: str, + path: str, + response_model: Type[ModelT], + *, + query: Optional[list[tuple[str, str]]] = None, + body: Optional[BaseModel] = None, + ) -> ModelT: + response = self._open(method, path, query=query, body=body, accept="application/json") + with response: + data = self._read_success(response) + + return response_model.model_validate_json(data) + + def request_bytes( + self, + method: str, + path: str, + *, + query: Optional[list[tuple[str, str]]] = None, + body: Optional[BaseModel] = None, + accept: str, + ) -> bytes: + response = self._open(method, path, query=query, body=body, accept=accept) + with response: + return self._read_success(response) + + def request_stream( + self, + method: str, + path: str, + *, + body: BaseModel, + accept: str, + ) -> BinaryIO: + return self._open(method, path, body=body, accept=accept) + + def request_empty(self, method: str, path: str) -> None: + response = self._open(method, path, accept="application/json") + with response: + self._read_success(response) + + def _open( + self, + method: str, + path: str, + *, + query: Optional[list[tuple[str, str]]] = None, + body: Optional[BaseModel] = None, + accept: str, + ) -> Any: + url = self.base_url + path + if query: + url += "?" + urlencode(query) + + headers = { + "Accept": accept, + "User-Agent": _USER_AGENT, + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + data = None + if body is not None: + data = body.model_dump_json( + by_alias=True, + exclude_none=True, + exclude_unset=True, + ).encode("utf-8") + headers["Content-Type"] = "application/json" + + retryable = method in _RETRYABLE_METHODS or ( + method == "POST" + and path.startswith("/openmeter/meters/") + and path.endswith(_RETRYABLE_POST_PATH_SUFFIX) + ) + retries_left = _MAX_RETRIES if retryable else 0 + attempt = 0 + while True: + request = Request(url, data=data, headers=headers, method=method) + try: + return self.opener.open(request, timeout=self.timeout) + except HTTPError as error: + with error: + raw_body, truncated = _read_error(error) + if retries_left > 0 and error.code in _RETRYABLE_STATUS_CODES: + retries_left -= 1 + time.sleep(_RETRY_BACKOFF_SECONDS * (2**attempt)) + attempt += 1 + continue + try: + problem = ProblemDetails.model_validate_json(raw_body) + except (ValidationError, ValueError): + problem = ProblemDetails(status=error.code) + raise APIError( + error.code, + problem, + raw_body, + raw_body_truncated=truncated, + ) from None + except (URLError, TimeoutError, OSError) as error: + if retries_left > 0: + retries_left -= 1 + time.sleep(_RETRY_BACKOFF_SECONDS * (2**attempt)) + attempt += 1 + continue + raise TransportError(f"OpenMeter request failed: {error}") from error + + @staticmethod + def _read_success(response: Any) -> bytes: + data = response.read(_BUFFER_LIMIT + 1) + if len(data) > _BUFFER_LIMIT: + raise ResponseTooLargeError(_BUFFER_LIMIT) + + return data + + +def _read_error(response: Any) -> tuple[bytes, bool]: + data = response.read(_ERROR_LIMIT + 1) + if len(data) > _ERROR_LIMIT: + return data[:_ERROR_LIMIT], True + + return data, False diff --git a/sdk/python/openmeter/src/openmeter/_transport_async.py b/sdk/python/openmeter/src/openmeter/_transport_async.py new file mode 100644 index 0000000000..9d98e0595a --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/_transport_async.py @@ -0,0 +1,205 @@ +"""httpx-based transport giving ``AsyncClient`` true async I/O.""" + +from __future__ import annotations + +import asyncio +from typing import Optional, Type, TypeVar +from urllib.parse import urlsplit + +import httpx +from pydantic import BaseModel, ValidationError + +from ._version import __version__ +from .errors import APIError, ResponseTooLargeError, TransportError +from .models import ProblemDetails + +_BUFFER_LIMIT = 10 * 1024 * 1024 +_ERROR_LIMIT = 1024 * 1024 +_USER_AGENT = f"openmeter-python/{__version__}" + +# GET/PUT/DELETE and read-only meter queries are safe to retry. Other POST +# requests create resources and could double-create when retried. +_RETRYABLE_METHODS = frozenset({"GET", "PUT", "DELETE"}) +_RETRYABLE_POST_PATH_SUFFIX = "/query" +_RETRYABLE_STATUS_CODES = frozenset({502, 503, 504}) +_MAX_RETRIES = 2 +_RETRY_BACKOFF_SECONDS = 0.5 + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +class _AsyncTransport: + def __init__( + self, + base_url: str, + *, + token: Optional[str], + timeout: Optional[float], + trust_env: bool, + client: Optional[httpx.AsyncClient], + ) -> None: + parsed = urlsplit(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("base_url must be an absolute HTTP or HTTPS URL") + if parsed.query or parsed.fragment: + raise ValueError("base_url must not contain a query string or fragment") + if timeout is not None and timeout <= 0: + raise ValueError("timeout must be positive or None") + + self.base_url = base_url.rstrip("/") + self.token = token + self._owns_client = client is None + self._client = ( + client + if client is not None + else httpx.AsyncClient(timeout=timeout, trust_env=trust_env) + ) + + async def request_json( + self, + method: str, + path: str, + response_model: Type[ModelT], + *, + query: Optional[list[tuple[str, str]]] = None, + body: Optional[BaseModel] = None, + ) -> ModelT: + response = await self._open(method, path, query=query, body=body, accept="application/json") + try: + data = await self._read_success(response) + finally: + await response.aclose() + + return response_model.model_validate_json(data) + + async def request_bytes( + self, + method: str, + path: str, + *, + query: Optional[list[tuple[str, str]]] = None, + body: Optional[BaseModel] = None, + accept: str, + ) -> bytes: + response = await self._open(method, path, query=query, body=body, accept=accept) + try: + return await self._read_success(response) + finally: + await response.aclose() + + async def request_stream( + self, + method: str, + path: str, + *, + body: BaseModel, + accept: str, + ) -> httpx.Response: + return await self._open(method, path, body=body, accept=accept) + + async def request_empty(self, method: str, path: str) -> None: + response = await self._open(method, path, accept="application/json") + try: + await self._read_success(response) + finally: + await response.aclose() + + async def aclose(self) -> None: + """Close the HTTP client when it was created by this transport.""" + + if self._owns_client: + await self._client.aclose() + + async def _open( + self, + method: str, + path: str, + *, + query: Optional[list[tuple[str, str]]] = None, + body: Optional[BaseModel] = None, + accept: str, + ) -> httpx.Response: + url = self.base_url + path + headers = { + "Accept": accept, + "User-Agent": _USER_AGENT, + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + content = None + if body is not None: + content = body.model_dump_json( + by_alias=True, + exclude_none=True, + exclude_unset=True, + ).encode("utf-8") + headers["Content-Type"] = "application/json" + + params = httpx.QueryParams(tuple(query)) if query else None + retryable = method in _RETRYABLE_METHODS or ( + method == "POST" + and path.startswith("/openmeter/meters/") + and path.endswith(_RETRYABLE_POST_PATH_SUFFIX) + ) + retries_left = _MAX_RETRIES if retryable else 0 + attempt = 0 + while True: + request = self._client.build_request( + method, url, params=params, content=content, headers=headers + ) + try: + response = await self._client.send(request, stream=True, follow_redirects=False) + except httpx.TransportError as error: + if retries_left > 0: + retries_left -= 1 + await asyncio.sleep(_RETRY_BACKOFF_SECONDS * (2**attempt)) + attempt += 1 + continue + raise TransportError(f"OpenMeter request failed: {error}") from error + + if 200 <= response.status_code < 300: + return response + + raw_body, truncated = await self._read_bounded(response, _ERROR_LIMIT) + await response.aclose() + if retries_left > 0 and response.status_code in _RETRYABLE_STATUS_CODES: + retries_left -= 1 + await asyncio.sleep(_RETRY_BACKOFF_SECONDS * (2**attempt)) + attempt += 1 + continue + + try: + problem = ProblemDetails.model_validate_json(raw_body) + except (ValidationError, ValueError): + problem = ProblemDetails(status=response.status_code) + raise APIError( + response.status_code, + problem, + raw_body, + raw_body_truncated=truncated, + ) from None + + @staticmethod + async def _read_success(response: httpx.Response) -> bytes: + data, truncated = await _AsyncTransport._read_bounded(response, _BUFFER_LIMIT) + if truncated: + raise ResponseTooLargeError(_BUFFER_LIMIT) + + return data + + @staticmethod + async def _read_bounded(response: httpx.Response, limit: int) -> tuple[bytes, bool]: + chunks: list[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + chunks.append(chunk) + total += len(chunk) + if total > limit: + break + + data = b"".join(chunks) + if len(data) > limit: + return data[:limit], True + + return data, False diff --git a/sdk/python/openmeter/src/openmeter/_version.py b/sdk/python/openmeter/src/openmeter/_version.py new file mode 100644 index 0000000000..a740ad4925 --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/_version.py @@ -0,0 +1,10 @@ +"""Package version, sourced from installed distribution metadata.""" + +from __future__ import annotations + +from importlib import metadata + +try: + __version__ = metadata.version("openmeter") +except metadata.PackageNotFoundError: # pragma: no cover - unbuilt source checkout + __version__ = "0.0.0+unknown" diff --git a/sdk/python/openmeter/src/openmeter/aio.py b/sdk/python/openmeter/src/openmeter/aio.py new file mode 100644 index 0000000000..930bd730c3 --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/aio.py @@ -0,0 +1,359 @@ +"""Asynchronous OpenMeter v3 client with true async I/O via httpx.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +import httpx + +from ._transport_async import _AsyncTransport +from .client import ( + _MAX_PAGES, + _METERS_PATH, + _last_page, + _meter_list_query, + _page_query, + _page_start, + _plan_addon_path, + _plan_addons_path, + _resource_path, +) +from .errors import PaginationError +from .models import ( + CreateMeterRequest, + CreatePlanAddonRequest, + Meter, + MeterListParams, + MeterPage, + MeterQueryRequest, + MeterQueryResult, + PageParams, + PlanAddon, + PlanAddonListParams, + PlanAddonPage, + UpdateMeterRequest, + UpsertPlanAddonRequest, +) + + +class AsyncClient: + """Asynchronous client for the OpenMeter v3 API baseline. + + HTTP calls use ``httpx.AsyncClient`` for true async I/O, so the event loop + is never blocked on network waits and no worker-thread pool is involved. + + ``token`` falls back to the ``OPENMETER_TOKEN`` environment variable when + not passed explicitly. The internal httpx client follows standard proxy + environment variables by default; pass ``trust_env=False`` to ignore them. + Pass ``client`` to fully customize proxy, TLS, HTTP/2, or authentication + behavior. ``timeout`` and ``trust_env`` only configure an internally created + client; the caller owns closing a supplied client. + """ + + def __init__( + self, + base_url: str = "https://openmeter.cloud/api/v3", + *, + token: Optional[str] = None, + timeout: Optional[float] = 30.0, + trust_env: bool = True, + client: Optional[httpx.AsyncClient] = None, + ) -> None: + transport = _AsyncTransport( + base_url, + token=token if token is not None else os.getenv("OPENMETER_TOKEN"), + timeout=timeout, + trust_env=trust_env, + client=client, + ) + self._transport = transport + self.meters = AsyncMeters(transport) + self.plan_addons = AsyncPlanAddons(transport) + + async def close(self) -> None: + """Close HTTP resources created and owned by this client.""" + + await self._transport.aclose() + + def __repr__(self) -> str: + return f"AsyncClient(base_url={self._transport.base_url!r})" + + async def __aenter__(self) -> AsyncClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + +class AsyncByteStream: + """Async wrapper around an httpx streaming response body.""" + + def __init__(self, response: httpx.Response) -> None: + self._response = response + self._iterator = response.aiter_bytes() + self._buffer = b"" + self._exhausted = False + + async def read(self, size: int = -1) -> bytes: + """Read up to ``size`` bytes, or the entire remaining body if negative.""" + + if size < 0: + chunks = [self._buffer] + self._buffer = b"" + async for chunk in self._iterator: + chunks.append(chunk) + self._exhausted = True + return b"".join(chunks) + + while len(self._buffer) < size and not self._exhausted: + try: + self._buffer += await self._iterator.__anext__() + except StopAsyncIteration: + self._exhausted = True + + data, self._buffer = self._buffer[:size], self._buffer[size:] + return data + + async def close(self) -> None: + """Close the underlying response body.""" + + await self._response.aclose() + + def __aiter__(self) -> AsyncByteStream: + return self + + async def __anext__(self) -> bytes: + while b"\n" not in self._buffer and not self._exhausted: + try: + self._buffer += await self._iterator.__anext__() + except StopAsyncIteration: + self._exhausted = True + + if not self._buffer: + raise StopAsyncIteration + + newline_index = self._buffer.find(b"\n") + if newline_index == -1: + line, self._buffer = self._buffer, b"" + else: + split_at = newline_index + 1 + line, self._buffer = self._buffer[:split_at], self._buffer[split_at:] + + return line + + async def __aenter__(self) -> AsyncByteStream: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + +class AsyncMeters: + """Asynchronous operations for v3 meter resources.""" + + def __init__(self, transport: _AsyncTransport) -> None: + self._transport = transport + + async def create(self, request: CreateMeterRequest) -> Meter: + """Create a meter and return the created resource.""" + + return await self._transport.request_json( + "POST", + _METERS_PATH, + Meter, + body=request, + ) + + async def get(self, meter_id: str) -> Meter: + """Retrieve a meter by ULID.""" + + return await self._transport.request_json( + "GET", _resource_path(_METERS_PATH, meter_id), Meter + ) + + async def update(self, meter_id: str, request: UpdateMeterRequest) -> Meter: + """Update a meter's mutable fields and return the updated resource. + + Omitting ``labels`` or ``dimensions`` clears them — see + ``UpdateMeterRequest`` for per-field merge-vs-replace behavior. + """ + + return await self._transport.request_json( + "PUT", + _resource_path(_METERS_PATH, meter_id), + Meter, + body=request, + ) + + async def delete(self, meter_id: str) -> None: + """Delete a meter by ULID.""" + + await self._transport.request_empty("DELETE", _resource_path(_METERS_PATH, meter_id)) + + async def list(self, params: Optional[MeterListParams] = None) -> MeterPage: + """Return one page of meters.""" + + params = params or MeterListParams() + return await self._transport.request_json( + "GET", + _METERS_PATH, + MeterPage, + query=_meter_list_query(params), + ) + + async def list_all(self, params: Optional[MeterListParams] = None) -> AsyncIterator[Meter]: + """Yield all meters while fetching successive pages lazily.""" + + params = params or MeterListParams() + page_number, page_size = _page_start(params.page) + for _ in range(_MAX_PAGES): + page_params = params.model_copy( + update={"page": PageParams(number=page_number, size=page_size)} + ) + response = await self.list(page_params) + if not response.data: + return + + for meter in response.data: + yield meter + if _last_page( + response.meta.page.number, response.meta.page.size, response.meta.page.total + ): + return + page_number += 1 + + raise PaginationError(f"automatic pagination exceeded {_MAX_PAGES} pages") + + async def query(self, meter_id: str, request: MeterQueryRequest) -> MeterQueryResult: + """Query aggregated meter usage and return structured JSON.""" + + return await self._transport.request_json( + "POST", + _resource_path(_METERS_PATH, meter_id) + "/query", + MeterQueryResult, + body=request, + ) + + async def query_csv(self, meter_id: str, request: MeterQueryRequest) -> bytes: + """Query aggregated usage and return a bounded CSV response.""" + + return await self._transport.request_bytes( + "POST", + _resource_path(_METERS_PATH, meter_id) + "/query", + body=request, + accept="text/csv", + ) + + @asynccontextmanager + async def query_csv_stream( + self, + meter_id: str, + request: MeterQueryRequest, + ) -> AsyncIterator[AsyncByteStream]: + """Open an asynchronously readable CSV stream that closes on context exit.""" + + response = await self._transport.request_stream( + "POST", + _resource_path(_METERS_PATH, meter_id) + "/query", + body=request, + accept="text/csv", + ) + stream = AsyncByteStream(response) + try: + yield stream + finally: + await stream.close() + + +class AsyncPlanAddons: + """Asynchronous operations for add-ons nested under a v3 plan.""" + + def __init__(self, transport: _AsyncTransport) -> None: + self._transport = transport + + async def create(self, plan_id: str, request: CreatePlanAddonRequest) -> PlanAddon: + """Associate an add-on with a plan.""" + + return await self._transport.request_json( + "POST", + _plan_addons_path(plan_id), + PlanAddon, + body=request, + ) + + async def get(self, plan_id: str, plan_addon_id: str) -> PlanAddon: + """Retrieve one plan-add-on association.""" + + return await self._transport.request_json( + "GET", + _plan_addon_path(plan_id, plan_addon_id), + PlanAddon, + ) + + async def update( + self, + plan_id: str, + plan_addon_id: str, + request: UpsertPlanAddonRequest, + ) -> PlanAddon: + """Update a plan-add-on association. + + See ``UpsertPlanAddonRequest`` for per-field merge-vs-replace + behavior, including which fields the server currently ignores. + """ + + return await self._transport.request_json( + "PUT", + _plan_addon_path(plan_id, plan_addon_id), + PlanAddon, + body=request, + ) + + async def delete(self, plan_id: str, plan_addon_id: str) -> None: + """Remove an add-on association from a plan.""" + + await self._transport.request_empty("DELETE", _plan_addon_path(plan_id, plan_addon_id)) + + async def list( + self, + plan_id: str, + params: Optional[PlanAddonListParams] = None, + ) -> PlanAddonPage: + """Return one page of add-ons associated with a plan.""" + + params = params or PlanAddonListParams() + return await self._transport.request_json( + "GET", + _plan_addons_path(plan_id), + PlanAddonPage, + query=_page_query(params.page), + ) + + async def list_all( + self, + plan_id: str, + params: Optional[PlanAddonListParams] = None, + ) -> AsyncIterator[PlanAddon]: + """Yield all add-on associations for a plan lazily.""" + + params = params or PlanAddonListParams() + page_number, page_size = _page_start(params.page) + for _ in range(_MAX_PAGES): + page_params = params.model_copy( + update={"page": PageParams(number=page_number, size=page_size)} + ) + response = await self.list(plan_id, page_params) + if not response.data: + return + + for plan_addon in response.data: + yield plan_addon + if _last_page( + response.meta.page.number, response.meta.page.size, response.meta.page.total + ): + return + page_number += 1 + + raise PaginationError(f"automatic pagination exceeded {_MAX_PAGES} pages") diff --git a/sdk/python/openmeter/src/openmeter/client.py b/sdk/python/openmeter/src/openmeter/client.py new file mode 100644 index 0000000000..6dd2ef70cd --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/client.py @@ -0,0 +1,348 @@ +"""Synchronous OpenMeter v3 client and resource groups.""" + +from __future__ import annotations + +import os +from typing import BinaryIO, Iterator, Optional, Union +from urllib.request import OpenerDirector + +from pydantic import TypeAdapter, ValidationError + +from ._transport import _Transport +from .errors import InvalidIDError, PaginationError +from .models import ( + ULID, + CreateMeterRequest, + CreatePlanAddonRequest, + Meter, + MeterListParams, + MeterPage, + MeterQueryRequest, + MeterQueryResult, + PageParams, + PlanAddon, + PlanAddonListParams, + PlanAddonPage, + StringFilter, + UpdateMeterRequest, + UpsertPlanAddonRequest, +) + +_METERS_PATH = "/openmeter/meters" +_PLANS_PATH = "/openmeter/plans" +_MAX_PAGES = 10_000 +_ULID_ADAPTER = TypeAdapter(ULID) + + +class Client: + """Synchronous client for the OpenMeter v3 API baseline. + + ``base_url`` must include the deployment's v3 prefix, for example + ``https://openmeter.cloud/api/v3``. The client uses only the Python standard + library for HTTP and accepts an optional ``urllib`` opener for custom proxy, + TLS, authentication, or test behavior. + + ``token`` falls back to the ``OPENMETER_TOKEN`` environment variable when + not passed explicitly. + """ + + def __init__( + self, + base_url: str = "https://openmeter.cloud/api/v3", + *, + token: Optional[str] = None, + timeout: Optional[float] = 30.0, + opener: Optional[OpenerDirector] = None, + ) -> None: + transport = _Transport( + base_url, + token=token if token is not None else os.getenv("OPENMETER_TOKEN"), + timeout=timeout, + opener=opener, + ) + self._transport = transport + self.meters = Meters(transport) + self.plan_addons = PlanAddons(transport) + + def close(self) -> None: + """Close the client. + + The standard-library opener owns no persistent user-managed resource, + so this method is currently a no-op and exists for context-manager use. + """ + + def __repr__(self) -> str: + return f"Client(base_url={self._transport.base_url!r})" + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +class Meters: + """Synchronous operations for v3 meter resources.""" + + def __init__(self, transport: _Transport) -> None: + self._transport = transport + + def create(self, request: CreateMeterRequest) -> Meter: + """Create a meter and return the created resource.""" + + return self._transport.request_json( + "POST", + _METERS_PATH, + Meter, + body=request, + ) + + def get(self, meter_id: str) -> Meter: + """Retrieve a meter by ULID.""" + + return self._transport.request_json("GET", _resource_path(_METERS_PATH, meter_id), Meter) + + def update(self, meter_id: str, request: UpdateMeterRequest) -> Meter: + """Update a meter's mutable fields and return the updated resource. + + Omitting ``labels`` or ``dimensions`` clears them — see + ``UpdateMeterRequest`` for per-field merge-vs-replace behavior. + """ + + return self._transport.request_json( + "PUT", + _resource_path(_METERS_PATH, meter_id), + Meter, + body=request, + ) + + def delete(self, meter_id: str) -> None: + """Delete a meter by ULID.""" + + self._transport.request_empty("DELETE", _resource_path(_METERS_PATH, meter_id)) + + def list(self, params: Optional[MeterListParams] = None) -> MeterPage: + """Return one page of meters.""" + + params = params or MeterListParams() + return self._transport.request_json( + "GET", + _METERS_PATH, + MeterPage, + query=_meter_list_query(params), + ) + + def list_all(self, params: Optional[MeterListParams] = None) -> Iterator[Meter]: + """Yield all meters while fetching successive pages lazily.""" + + params = params or MeterListParams() + page_number, page_size = _page_start(params.page) + for _ in range(_MAX_PAGES): + page_params = params.model_copy( + update={"page": PageParams(number=page_number, size=page_size)} + ) + response = self.list(page_params) + if not response.data: + return + + yield from response.data + if _last_page( + response.meta.page.number, response.meta.page.size, response.meta.page.total + ): + return + page_number += 1 + + raise PaginationError(f"automatic pagination exceeded {_MAX_PAGES} pages") + + def query(self, meter_id: str, request: MeterQueryRequest) -> MeterQueryResult: + """Query aggregated meter usage and return structured JSON.""" + + return self._transport.request_json( + "POST", + _resource_path(_METERS_PATH, meter_id) + "/query", + MeterQueryResult, + body=request, + ) + + def query_csv(self, meter_id: str, request: MeterQueryRequest) -> bytes: + """Query aggregated usage and return a bounded CSV response.""" + + return self._transport.request_bytes( + "POST", + _resource_path(_METERS_PATH, meter_id) + "/query", + body=request, + accept="text/csv", + ) + + def query_csv_stream(self, meter_id: str, request: MeterQueryRequest) -> BinaryIO: + """Query usage and return an unbuffered CSV body that the caller closes.""" + + return self._transport.request_stream( + "POST", + _resource_path(_METERS_PATH, meter_id) + "/query", + body=request, + accept="text/csv", + ) + + +class PlanAddons: + """Synchronous operations for add-ons nested under a v3 plan.""" + + def __init__(self, transport: _Transport) -> None: + self._transport = transport + + def create(self, plan_id: str, request: CreatePlanAddonRequest) -> PlanAddon: + """Associate an add-on with a plan.""" + + return self._transport.request_json( + "POST", + _plan_addons_path(plan_id), + PlanAddon, + body=request, + ) + + def get(self, plan_id: str, plan_addon_id: str) -> PlanAddon: + """Retrieve one plan-add-on association.""" + + return self._transport.request_json( + "GET", + _plan_addon_path(plan_id, plan_addon_id), + PlanAddon, + ) + + def update( + self, + plan_id: str, + plan_addon_id: str, + request: UpsertPlanAddonRequest, + ) -> PlanAddon: + """Update a plan-add-on association. + + See ``UpsertPlanAddonRequest`` for per-field merge-vs-replace + behavior, including which fields the server currently ignores. + """ + + return self._transport.request_json( + "PUT", + _plan_addon_path(plan_id, plan_addon_id), + PlanAddon, + body=request, + ) + + def delete(self, plan_id: str, plan_addon_id: str) -> None: + """Remove an add-on association from a plan.""" + + self._transport.request_empty("DELETE", _plan_addon_path(plan_id, plan_addon_id)) + + def list( + self, + plan_id: str, + params: Optional[PlanAddonListParams] = None, + ) -> PlanAddonPage: + """Return one page of add-ons associated with a plan.""" + + params = params or PlanAddonListParams() + return self._transport.request_json( + "GET", + _plan_addons_path(plan_id), + PlanAddonPage, + query=_page_query(params.page), + ) + + def list_all( + self, + plan_id: str, + params: Optional[PlanAddonListParams] = None, + ) -> Iterator[PlanAddon]: + """Yield all add-on associations for a plan lazily.""" + + params = params or PlanAddonListParams() + page_number, page_size = _page_start(params.page) + for _ in range(_MAX_PAGES): + page_params = params.model_copy( + update={"page": PageParams(number=page_number, size=page_size)} + ) + response = self.list(plan_id, page_params) + if not response.data: + return + + yield from response.data + if _last_page( + response.meta.page.number, response.meta.page.size, response.meta.page.total + ): + return + page_number += 1 + + raise PaginationError(f"automatic pagination exceeded {_MAX_PAGES} pages") + + +def _resource_path(base: str, resource_id: str) -> str: + try: + resource_id = _ULID_ADAPTER.validate_python(resource_id) + except ValidationError: + raise InvalidIDError("resource ID must be a valid ULID") from None + + return base + "/" + resource_id + + +def _plan_addons_path(plan_id: str) -> str: + return _resource_path(_PLANS_PATH, plan_id) + "/addons" + + +def _plan_addon_path(plan_id: str, plan_addon_id: str) -> str: + return _resource_path(_plan_addons_path(plan_id), plan_addon_id) + + +def _page_query(page: Optional[PageParams]) -> list[tuple[str, str]]: + query: list[tuple[str, str]] = [] + if page is not None: + if page.size is not None: + query.append(("page[size]", str(page.size))) + if page.number is not None: + query.append(("page[number]", str(page.number))) + + return query + + +def _meter_list_query(params: MeterListParams) -> list[tuple[str, str]]: + query = _page_query(params.page) + if params.sort: + query.append(("sort", ",".join(params.sort))) + if params.filter is not None: + _add_string_filter(query, "filter[key]", params.filter.key) + _add_string_filter(query, "filter[name]", params.filter.name) + + return query + + +def _add_string_filter( + query: list[tuple[str, str]], + name: str, + value: Optional[Union[str, StringFilter]], +) -> None: + if value is None: + return + if isinstance(value, str): + query.append((name, value)) + return + + for operator, operand in value.model_dump(exclude_none=True).items(): + encoded_operator = "$exists" if operator == "exists" else operator + if isinstance(operand, list): + encoded_operand = ",".join(operand) + elif isinstance(operand, bool): + encoded_operand = str(operand).lower() + else: + encoded_operand = str(operand) + query.append((f"{name}[{encoded_operator}]", encoded_operand)) + + +def _page_start(page: Optional[PageParams]) -> tuple[int, int]: + if page is None: + return 1, 100 + + return page.number or 1, page.size or 100 + + +def _last_page(number: int, size: int, total: int) -> bool: + return total > 0 and number * size >= total diff --git a/sdk/python/openmeter/src/openmeter/errors.py b/sdk/python/openmeter/src/openmeter/errors.py new file mode 100644 index 0000000000..5c840dc2ce --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/errors.py @@ -0,0 +1,95 @@ +"""Exceptions raised by the OpenMeter v3 SDK.""" + +from __future__ import annotations + +from typing import Optional + +from .models import InvalidParameter, ProblemDetails + + +class OpenMeterError(Exception): + """Base class for SDK-specific errors.""" + + +class InvalidIDError(OpenMeterError, ValueError): + """Raised before a request when a resource ID is not a valid ULID.""" + + +class TransportError(OpenMeterError): + """Raised when no HTTP response could be obtained.""" + + +class ResponseTooLargeError(OpenMeterError): + """Raised when a buffered success response exceeds the safety limit.""" + + def __init__(self, limit: int) -> None: + self.limit = limit + super().__init__(f"response body exceeds the {limit}-byte buffer limit") + + +class PaginationError(OpenMeterError): + """Raised when automatic pagination exceeds its loop-safety limit.""" + + +class APIError(OpenMeterError): + """Non-successful HTTP response returned by the OpenMeter API. + + Attributes expose the stable RFC 7807 fields while ``raw_body`` preserves + the response bytes for diagnostics. Error bodies are capped before they are + stored, and ``raw_body_truncated`` reports when the cap was reached. + """ + + def __init__( + self, + status_code: int, + problem: ProblemDetails, + raw_body: bytes, + *, + raw_body_truncated: bool = False, + ) -> None: + self.status_code = status_code + self.problem = problem + self.raw_body = raw_body + self.raw_body_truncated = raw_body_truncated + super().__init__(self._message()) + + @property + def title(self) -> Optional[str]: + """Short, stable description of the problem category.""" + + return self.problem.title + + @property + def detail(self) -> Optional[str]: + """Human-readable detail for this occurrence.""" + + return self.problem.detail + + @property + def type(self) -> Optional[str]: + """Problem type identifier supplied by the API.""" + + return self.problem.type + + @property + def instance(self) -> Optional[str]: + """Correlation identifier supplied by the API.""" + + return self.problem.instance + + @property + def invalid_parameters(self) -> list[InvalidParameter]: + """Per-field validation issues supplied by the API.""" + + return self.problem.invalid_parameters + + def _message(self) -> str: + summary = self.detail or self.title + if summary: + return f"OpenMeter API returned HTTP {self.status_code}: {summary}" + + raw = self.raw_body[:512].decode("utf-8", errors="replace").strip() + if raw: + return f"OpenMeter API returned HTTP {self.status_code}: {raw}" + + return f"OpenMeter API returned HTTP {self.status_code}" diff --git a/sdk/python/openmeter/src/openmeter/models.py b/sdk/python/openmeter/src/openmeter/models.py new file mode 100644 index 0000000000..4ffffb4bb4 --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/models.py @@ -0,0 +1,379 @@ +"""Pydantic models for the OpenMeter v3 SDK baseline.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Annotated, Any, Literal, Optional, Union + +from pydantic import ( + AfterValidator, + AwareDatetime, + BaseModel, + ConfigDict, + Field, + StringConstraints, + model_validator, +) + +ULID = Annotated[ + str, + StringConstraints(pattern=r"^[0-7][0-9A-HJKMNP-TV-Z]{25}$"), +] +ResourceKey = Annotated[ + str, + StringConstraints( + min_length=1, + max_length=64, + pattern=r"^[a-z0-9]+(?:_[a-z0-9]+)*$", + ), +] +ResourceName = Annotated[str, StringConstraints(min_length=1, max_length=256)] +ResourceDescription = Annotated[str, StringConstraints(max_length=1024)] +LabelValue = Annotated[ + str, + StringConstraints( + min_length=1, + max_length=63, + pattern=r"^[a-z0-9A-Z]{1}([a-z0-9A-Z-._]*[a-z0-9A-Z]+)?$", + ), +] + +_RESERVED_LABEL_PREFIXES = ( + "_", + "kong", + "konnect", + "mesh", + "kic", +) + + +def _validate_label_key(value: str) -> str: + if value.startswith(_RESERVED_LABEL_PREFIXES): + raise ValueError("label key must not use a reserved prefix") + + return value + + +LabelKey = Annotated[LabelValue, AfterValidator(_validate_label_key)] +Labels = Annotated[dict[LabelKey, LabelValue], Field(max_length=50)] + +MeterAggregation = Literal[ + "sum", + "count", + "unique_count", + "avg", + "min", + "max", + "latest", +] +MeterQueryGranularity = Literal["PT1M", "PT1H", "P1D", "P1M"] + + +class _RequestModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class _ResponseModel(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +class PageParams(_RequestModel): + """Page number and size for page-based v3 collection endpoints.""" + + size: Optional[int] = Field(default=None, gt=0) + number: Optional[int] = Field(default=None, gt=0) + + +class PageMeta(_ResponseModel): + """Pagination values returned by the API.""" + + number: int + size: int + total: int + + +class PaginatedMeta(_ResponseModel): + """Pagination metadata envelope returned by list operations.""" + + page: PageMeta + + +class StringFilter(_RequestModel): + """One string comparison used by meter list filters. + + Exactly one operator must be provided. ``oeq`` and ``ocontains`` accept + Python lists and are encoded as the API's comma-delimited query value. + """ + + eq: Optional[str] = None + neq: Optional[str] = None + contains: Optional[str] = None + ocontains: Optional[list[str]] = Field(default=None, min_length=1) + oeq: Optional[list[str]] = Field(default=None, min_length=1) + gt: Optional[str] = None + gte: Optional[str] = None + lt: Optional[str] = None + lte: Optional[str] = None + exists: Optional[bool] = None + + @model_validator(mode="after") + def validate_operator(self) -> StringFilter: + """Require one representable filter operator.""" + + values = [getattr(self, name) for name in self.__class__.model_fields] + if sum(value is not None for value in values) != 1: + raise ValueError("exactly one string filter operator must be set") + + for values in (self.oeq, self.ocontains): + if values is not None and any("," in value for value in values): + raise ValueError("oeq and ocontains values must not contain commas") + + return self + + +class MeterFilter(_RequestModel): + """Filters supported by the v3 list-meters endpoint.""" + + key: Optional[Union[str, StringFilter]] = None + name: Optional[Union[str, StringFilter]] = None + + +class MeterListParams(_RequestModel): + """Optional pagination, sorting, and filtering for listing meters.""" + + page: Optional[PageParams] = None + sort: list[str] = Field(default_factory=list) + filter: Optional[MeterFilter] = None + + +class PlanAddonListParams(_RequestModel): + """Optional pagination for listing a plan's add-on associations.""" + + page: Optional[PageParams] = None + + +class _StringQueryFilter(_RequestModel): + eq: Optional[str] = None + neq: Optional[str] = None + in_: Optional[list[str]] = Field(default=None, alias="in", min_length=1, max_length=100) + nin: Optional[list[str]] = Field(default=None, min_length=1, max_length=100) + contains: Optional[str] = None + ncontains: Optional[str] = None + and_: Optional[list[QueryFilterString]] = Field( + default=None, + alias="and", + min_length=1, + max_length=10, + ) + or_: Optional[list[QueryFilterString]] = Field( + default=None, + alias="or", + min_length=1, + max_length=10, + ) + + @model_validator(mode="after") + def validate_operator(self) -> _StringQueryFilter: + """Require the mutually exclusive query-filter operator.""" + + values = [getattr(self, name) for name in self.__class__.model_fields] + if sum(value is not None for value in values) != 1: + raise ValueError("exactly one query filter operator must be set") + + return self + + +class QueryFilterString(_StringQueryFilter): + """Recursive string filter used inside logical query expressions.""" + + +class QueryFilterStringMapItem(_StringQueryFilter): + """String filter used for one meter-query dimension.""" + + exists: Optional[bool] = None + + +class MeterQueryFilters(_RequestModel): + """Dimension filters for a meter usage query.""" + + dimensions: dict[str, QueryFilterStringMapItem] = Field(default_factory=dict, max_length=10) + + +class CreateMeterRequest(_RequestModel): + """Request body for creating a meter.""" + + name: ResourceName + key: ResourceKey + aggregation: MeterAggregation + event_type: Annotated[str, StringConstraints(min_length=1)] + description: Optional[ResourceDescription] = None + labels: Optional[Labels] = None + events_from: Optional[AwareDatetime] = None + value_property: Optional[Annotated[str, StringConstraints(min_length=1)]] = None + dimensions: Optional[dict[str, str]] = None + + +class UpdateMeterRequest(_RequestModel): + """Request body for updating a meter's mutable fields. + + The request schema makes every field optional, but the server currently + rejects updates that omit ``name``. ``description`` is preserved when + omitted, while omitting ``labels`` or ``dimensions`` clears that field. + """ + + name: Optional[ResourceName] = None + description: Optional[ResourceDescription] = None + labels: Optional[Labels] = None + dimensions: Optional[dict[str, str]] = None + + +class Meter(_ResponseModel): + """Meter configuration returned by the v3 API. + + ``aggregation`` intentionally remains a plain string so newly introduced + server values remain readable by older SDK versions. + """ + + id: ULID + key: ResourceKey + name: ResourceName + aggregation: str + event_type: Annotated[str, StringConstraints(min_length=1)] + created_at: AwareDatetime + updated_at: AwareDatetime + description: Optional[ResourceDescription] = None + labels: dict[str, LabelValue] = Field(default_factory=dict, max_length=50) + deleted_at: Optional[AwareDatetime] = None + events_from: Optional[AwareDatetime] = None + value_property: Optional[Annotated[str, StringConstraints(min_length=1)]] = None + dimensions: dict[str, str] = Field(default_factory=dict) + + +class MeterPage(_ResponseModel): + """One page of meters and its pagination metadata.""" + + data: list[Meter] + meta: PaginatedMeta + + +class MeterQueryRequest(_RequestModel): + """Request body for querying aggregated meter usage.""" + + from_: Optional[AwareDatetime] = Field(default=None, alias="from") + to: Optional[AwareDatetime] = None + granularity: Optional[MeterQueryGranularity] = None + time_zone: str = "UTC" + group_by_dimensions: list[str] = Field(default_factory=list, max_length=100) + filters: Optional[MeterQueryFilters] = None + + +class MeterQueryRow(_ResponseModel): + """One aggregated bucket in a meter query result.""" + + value: Decimal = Field(allow_inf_nan=False) + from_: AwareDatetime = Field(alias="from") + to: AwareDatetime + dimensions: dict[str, str] + + +class MeterQueryResult(_ResponseModel): + """Structured JSON result returned by a meter usage query.""" + + data: list[MeterQueryRow] + from_: Optional[AwareDatetime] = Field(default=None, alias="from") + to: Optional[AwareDatetime] = None + + +class AddonReference(_RequestModel): + """Reference to an add-on by ULID.""" + + id: ULID + + +class ProductCatalogValidationError(_ResponseModel): + """Server-reported validation issue on a plan-add-on association.""" + + code: str + message: str + field: str + attributes: dict[str, Any] = Field(default_factory=dict) + + +class CreatePlanAddonRequest(_RequestModel): + """Request body for associating an add-on with a plan.""" + + name: ResourceName + addon: AddonReference + from_plan_phase: ResourceKey + description: Optional[ResourceDescription] = None + labels: Optional[Labels] = None + max_quantity: Optional[int] = Field(default=None, ge=1) + + +class UpsertPlanAddonRequest(_RequestModel): + """Request body for updating a plan-add-on association. + + ``name`` and ``from_plan_phase`` are required by the request schema. + The server currently ignores ``name`` and ``description``, preserves + ``labels`` when omitted, and resets ``max_quantity`` to unlimited when + omitted. + """ + + name: ResourceName + from_plan_phase: ResourceKey + description: Optional[ResourceDescription] = None + labels: Optional[Labels] = None + max_quantity: Optional[int] = Field(default=None, ge=1) + + +class PlanAddon(_ResponseModel): + """Association controlling which add-on is available for a plan.""" + + id: ULID + name: ResourceName + addon: AddonReference + from_plan_phase: ResourceKey + created_at: AwareDatetime + updated_at: AwareDatetime + description: Optional[ResourceDescription] = None + labels: dict[str, LabelValue] = Field(default_factory=dict, max_length=50) + max_quantity: Optional[int] = Field(default=None, ge=1) + validation_errors: list[ProductCatalogValidationError] = Field(default_factory=list) + deleted_at: Optional[AwareDatetime] = None + + +class PlanAddonPage(_ResponseModel): + """One page of plan-add-on associations and pagination metadata.""" + + data: list[PlanAddon] + meta: PaginatedMeta + + +class InvalidParameter(_ResponseModel): + """Machine-readable context for one invalid request parameter.""" + + field: str + reason: str + rule: Optional[str] = None + source: Optional[str] = None + minimum: Optional[int] = None + maximum: Optional[int] = None + choices: Optional[list[Any]] = None + dependents: Optional[list[Any]] = None + + +class ProblemDetails(BaseModel): + """RFC 7807-style error document returned by OpenMeter v3.""" + + model_config = ConfigDict(extra="allow") + + status: Optional[int] = None + title: Optional[str] = None + type: Optional[str] = None + instance: Optional[str] = None + detail: Optional[str] = None + invalid_parameters: list[InvalidParameter] = Field(default_factory=list) + + +QueryFilterString.model_rebuild() +QueryFilterStringMapItem.model_rebuild() diff --git a/sdk/python/openmeter/src/openmeter/py.typed b/sdk/python/openmeter/src/openmeter/py.typed new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/sdk/python/openmeter/src/openmeter/py.typed @@ -0,0 +1 @@ + diff --git a/sdk/python/openmeter/tests/__init__.py b/sdk/python/openmeter/tests/__init__.py new file mode 100644 index 0000000000..0bf452b14b --- /dev/null +++ b/sdk/python/openmeter/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenMeter Python SDK baseline.""" diff --git a/sdk/python/openmeter/tests/test_sdk.py b/sdk/python/openmeter/tests/test_sdk.py new file mode 100644 index 0000000000..3cc75a6bec --- /dev/null +++ b/sdk/python/openmeter/tests/test_sdk.py @@ -0,0 +1,852 @@ +"""Behavioral tests for sync and async v3 SDK clients.""" + +from __future__ import annotations + +import asyncio +import io +import json +import os +import subprocess +import sys +import threading +import unittest +from datetime import datetime, timezone +from decimal import Decimal +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Optional +from unittest.mock import Mock, patch +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlsplit + +import httpx +from pydantic import ValidationError + +from openmeter import ( + AddonReference, + APIError, + AsyncClient, + Client, + CreateMeterRequest, + CreatePlanAddonRequest, + InvalidIDError, + Meter, + MeterFilter, + MeterListParams, + MeterQueryFilters, + MeterQueryRequest, + PageParams, + PlanAddonListParams, + ProblemDetails, + QueryFilterString, + QueryFilterStringMapItem, + ResponseTooLargeError, + StringFilter, + TransportError, + UpdateMeterRequest, + UpsertPlanAddonRequest, +) + +METER_ID = "01G65Z755AFWAKHE12NY0CQ9FH" +SECOND_METER_ID = "01G65Z755AFWAKHE12NY0CQ9FJ" +PLAN_ID = "01G65Z755AFWAKHE12NY0CQ9FK" +PLAN_ADDON_ID = "01G65Z755AFWAKHE12NY0CQ9FM" +ADDON_ID = "01G65Z755AFWAKHE12NY0CQ9FN" +ERROR_ID = "01G65Z755AFWAKHE12NY0CQ9FP" +REDIRECT_ID = "01G65Z755AFWAKHE12NY0CQ9FQ" +NOW = "2026-07-14T10:00:00Z" +BUFFER_LIMIT = 10 * 1024 * 1024 +ERROR_LIMIT = 1024 * 1024 + + +def _meter(meter_id: str = METER_ID, key: str = "tokens_total") -> dict[str, Any]: + return { + "id": meter_id, + "key": key, + "name": "Tokens Total", + "aggregation": "sum", + "event_type": "prompt", + "created_at": NOW, + "updated_at": NOW, + "unknown_future_field": "accepted", + } + + +def _plan_addon() -> dict[str, Any]: + return { + "id": PLAN_ADDON_ID, + "name": "Pro add-on", + "addon": {"id": ADDON_ID}, + "from_plan_phase": "default", + "created_at": NOW, + "updated_at": NOW, + "validation_errors": [], + } + + +class _Handler(BaseHTTPRequestHandler): + requests: list[dict[str, Any]] = [] + # Maps a path to the number of remaining transient 503s to return before + # falling through to its normal response; used to test retry behavior. + fail_counts: dict[str, int] = {} + response_gate: Optional[threading.Event] = None + + def _maybe_fail(self, path: str) -> bool: + remaining = self.fail_counts.get(path, 0) + if remaining <= 0: + return False + + self.fail_counts[path] = remaining - 1 + self._json({"status": 503, "title": "Service Unavailable"}, status=503) + return True + + def do_GET(self) -> None: # noqa: N802 + path, query, _ = self._capture() + if self.response_gate is not None and not self.response_gate.wait(timeout=1): + self._json({"status": 500, "title": "Event loop blocked"}, status=500) + return + if self._maybe_fail(path): + return + if path == "/openmeter/meters": + page = int(query.get("page[number]", ["1"])[0]) + data = [_meter()] if page == 1 else [_meter(SECOND_METER_ID, "requests_total")] + self._json( + { + "data": data, + "meta": {"page": {"number": page, "size": 1, "total": 2}}, + } + ) + return + if path.endswith(REDIRECT_ID): + self.send_response(302) + self.send_header("Location", f"/api/v3/openmeter/meters/{METER_ID}") + self.end_headers() + return + if path.endswith(ERROR_ID): + self._json( + { + "status": 404, + "title": "Not Found", + "type": "https://httpstatuses.com/404", + "instance": "kong:trace:test", + "detail": "Meter was not found", + }, + status=404, + content_type="application/problem+json", + ) + return + if path.endswith("/addons"): + self._json( + { + "data": [_plan_addon()], + "meta": {"page": {"number": 1, "size": 100, "total": 1}}, + } + ) + return + if "/addons/" in path: + self._json(_plan_addon()) + return + + self._json(_meter()) + + def do_POST(self) -> None: # noqa: N802 + path, _, body = self._capture() + if self._maybe_fail(path): + return + if path.endswith("/query"): + if self.headers.get("Accept") == "text/csv": + self._bytes( + b"from,to,value\n2026-07-14T09:00:00Z,2026-07-14T10:00:00Z,12\n", "text/csv" + ) + else: + self._json( + { + "from": "2026-07-14T09:00:00Z", + "to": NOW, + "data": [ + { + "value": "12.50", + "from": "2026-07-14T09:00:00Z", + "to": NOW, + "dimensions": {"model": "gpt-4.1"}, + } + ], + } + ) + return + if path.endswith("/addons"): + response = _plan_addon() + response.update({key: value for key, value in body.items() if key != "addon"}) + self._json(response, status=201) + return + + response = _meter(key=body["key"]) + response.update( + {key: value for key, value in body.items() if key not in {"key", "aggregation"}} + ) + self._json(response, status=201) + + def do_PUT(self) -> None: # noqa: N802 + path, _, body = self._capture() + response = _plan_addon() if "/addons/" in path else _meter() + response.update(body) + self._json(response) + + def do_DELETE(self) -> None: # noqa: N802 + self._capture() + self.send_response(204) + self.end_headers() + + def _capture(self) -> tuple[str, dict[str, list[str]], dict[str, Any]]: + parsed = urlsplit(self.path) + path = parsed.path.removeprefix("/api/v3") + length = int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length) if length else b"" + body = json.loads(raw_body) if raw_body else {} + query = parse_qs(parsed.query) + self.__class__.requests.append( + { + "method": self.command, + "path": path, + "raw_path": self.path, + "query": query, + "headers": dict(self.headers), + "body": body, + } + ) + return path, query, body + + def _json( + self, + value: dict[str, Any], + *, + status: int = 200, + content_type: str = "application/json", + ) -> None: + self._bytes(json.dumps(value).encode(), content_type, status=status) + + def _bytes(self, value: bytes, content_type: str, *, status: int = 200) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(value))) + self.end_headers() + self.wfile.write(value) + + def log_message(self, *_: object) -> None: + return + + +_server: Optional[ThreadingHTTPServer] = None +_server_thread: Optional[threading.Thread] = None +BASE_URL = "" + + +def setUpModule() -> None: # noqa: N802 + global _server, _server_thread, BASE_URL + _server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + _server_thread = threading.Thread(target=_server.serve_forever, daemon=True) + _server_thread.start() + BASE_URL = f"http://127.0.0.1:{_server.server_port}/api/v3" + + +def tearDownModule() -> None: # noqa: N802 + if _server is not None: + _server.shutdown() + _server.server_close() + if _server_thread is not None: + _server_thread.join(timeout=5) + + +class ModelTests(unittest.TestCase): + def test_request_models_validate_openapi_constraints(self) -> None: + with self.assertRaises(ValidationError): + CreateMeterRequest(name="", key="Tokens", aggregation="median", event_type="") + + with self.assertRaises(ValidationError): + CreatePlanAddonRequest( + name="Pro", + addon=AddonReference(id=ADDON_ID), + from_plan_phase="default", + max_quantity=0, + ) + + with self.assertRaises(ValidationError): + MeterQueryRequest(from_=datetime(2026, 7, 14, 9)) + + def test_query_filter_requires_exactly_one_operator(self) -> None: + self.assertEqual(QueryFilterStringMapItem(eq="acme").eq, "acme") + nested = QueryFilterStringMapItem(and_=[QueryFilterString(eq="acme")]) + self.assertEqual(nested.and_[0].eq, "acme") + + with self.assertRaises(ValidationError): + QueryFilterStringMapItem(eq="acme", contains="ac") + + def test_response_models_accept_future_fields_and_enum_values(self) -> None: + value = _meter() + value["aggregation"] = "future_aggregation" + meter = Meter.model_validate(value) + self.assertEqual(meter.aggregation, "future_aggregation") + + def test_api_error_exposes_invalid_parameters(self) -> None: + problem = ProblemDetails.model_validate( + {"invalid_parameters": [{"field": "name", "reason": "must not be empty"}]} + ) + error = APIError(400, problem, b"") + + self.assertEqual(error.invalid_parameters[0].field, "name") + + def test_request_labels_validate_keys(self) -> None: + request = CreateMeterRequest( + name="Tokens Total", + key="tokens_total", + aggregation="sum", + event_type="prompt", + labels={"openmeter_internal": "platform"}, + ) + self.assertEqual(request.labels, {"openmeter_internal": "platform"}) + + invalid_keys = [ + "", + "bad_", + "a" * 64, + "_internal", + "kong_internal", + "konnect_internal", + "mesh_internal", + "kic_internal", + ] + for label_key in invalid_keys: + with self.subTest(label_key=label_key): + with self.assertRaises(ValidationError): + CreateMeterRequest( + name="Tokens Total", + key="tokens_total", + aggregation="sum", + event_type="prompt", + labels={label_key: "value"}, + ) + + def test_sync_import_does_not_require_async_dependency(self) -> None: + script = """ +import builtins + +real_import = builtins.__import__ + + +def import_without_httpx(name, *args, **kwargs): + if name == "httpx" or name.startswith("httpx."): + raise ModuleNotFoundError("No module named 'httpx'", name="httpx") + return real_import(name, *args, **kwargs) + + +builtins.__import__ = import_without_httpx + +import openmeter + +assert openmeter.Client +try: + openmeter.AsyncClient +except ImportError as error: + assert "openmeter[async]" in str(error) +else: + raise AssertionError("AsyncClient must require the async extra") +""" + subprocess.run([sys.executable, "-c", script], check=True) + + +class SyncClientTests(unittest.TestCase): + def setUp(self) -> None: + _Handler.requests.clear() + _Handler.fail_counts.clear() + self.client = Client(BASE_URL, token="om_test") + + def test_meter_crud_list_filters_and_pagination(self) -> None: + request = CreateMeterRequest( + name="Tokens Total", + key="tokens_total", + aggregation="sum", + event_type="prompt", + ) + self.assertEqual(self.client.meters.create(request).key, "tokens_total") + self.assertEqual(self.client.meters.get(METER_ID).id, METER_ID) + self.assertEqual( + self.client.meters.update(METER_ID, UpdateMeterRequest(name="Renamed")).name, + "Renamed", + ) + + params = MeterListParams( + page=PageParams(number=1, size=1), + sort=["name", "created_at desc"], + filter=MeterFilter( + key=StringFilter(oeq=["tokens_total", "requests_total"]), + name=StringFilter(exists=True), + ), + ) + page = self.client.meters.list(params) + self.assertEqual(page.meta.page.total, 2) + self.assertEqual( + [meter.id for meter in self.client.meters.list_all(params)], [METER_ID, SECOND_METER_ID] + ) + self.client.meters.delete(METER_ID) + + list_request = next(request for request in _Handler.requests if request["query"]) + self.assertEqual(list_request["query"]["page[size]"], ["1"]) + self.assertEqual(list_request["query"]["sort"], ["name,created_at desc"]) + self.assertEqual( + list_request["query"]["filter[key][oeq]"], + ["tokens_total,requests_total"], + ) + self.assertEqual(list_request["query"]["filter[name][$exists]"], ["true"]) + self.assertEqual(_Handler.requests[0]["headers"]["Authorization"], "Bearer om_test") + self.assertTrue( + _Handler.requests[0]["headers"]["User-Agent"].startswith("openmeter-python/") + ) + + def test_meter_query_json_csv_and_stream(self) -> None: + request = MeterQueryRequest( + from_=datetime(2026, 7, 14, 9, tzinfo=timezone.utc), + group_by_dimensions=["model"], + filters=MeterQueryFilters(dimensions={"model": QueryFilterStringMapItem(eq="gpt-4.1")}), + ) + result = self.client.meters.query(METER_ID, request) + self.assertEqual(result.data[0].value, Decimal("12.50")) + self.assertEqual( + self.client.meters.query_csv(METER_ID, request).splitlines()[0], b"from,to,value" + ) + + with self.client.meters.query_csv_stream(METER_ID, request) as stream: + self.assertEqual(stream.readline(), b"from,to,value\n") + + query_body = _Handler.requests[0]["body"] + self.assertIn("from", query_body) + self.assertNotIn("from_", query_body) + self.assertNotIn("time_zone", query_body) + + def test_plan_addon_crud_list_and_pagination(self) -> None: + create = CreatePlanAddonRequest( + name="Pro add-on", + addon=AddonReference(id=ADDON_ID), + from_plan_phase="default", + ) + self.assertEqual(self.client.plan_addons.create(PLAN_ID, create).addon.id, ADDON_ID) + self.assertEqual(self.client.plan_addons.get(PLAN_ID, PLAN_ADDON_ID).id, PLAN_ADDON_ID) + update = UpsertPlanAddonRequest(name="Renamed", from_plan_phase="default") + self.assertEqual( + self.client.plan_addons.update(PLAN_ID, PLAN_ADDON_ID, update).name, + "Renamed", + ) + params = PlanAddonListParams(page=PageParams(number=1, size=100)) + self.assertEqual(len(list(self.client.plan_addons.list_all(PLAN_ID, params))), 1) + self.client.plan_addons.delete(PLAN_ID, PLAN_ADDON_ID) + + def test_api_error_and_invalid_ids(self) -> None: + with self.assertRaises(APIError) as raised: + self.client.meters.get(ERROR_ID) + self.assertEqual(raised.exception.status_code, 404) + self.assertEqual(raised.exception.instance, "kong:trace:test") + + for resource_id in ("", "invalid/id", "not-a-ulid"): + with self.subTest(resource_id=resource_id): + with self.assertRaises(InvalidIDError): + self.client.meters.get(resource_id) + + def test_redirect_response_raises_api_error_without_following(self) -> None: + with self.assertRaises(APIError) as raised: + self.client.meters.get(REDIRECT_ID) + + self.assertEqual(raised.exception.status_code, 302) + self.assertEqual(len(_Handler.requests), 1) + + def test_token_falls_back_to_environment(self) -> None: + with patch.dict(os.environ, {"OPENMETER_TOKEN": "om_env"}): + client = Client(BASE_URL) + client.meters.get(METER_ID) + + self.assertEqual(_Handler.requests[-1]["headers"]["Authorization"], "Bearer om_env") + + def test_buffered_success_response_is_bounded(self) -> None: + opener = Mock() + opener.open.return_value = io.BytesIO(b"x" * (BUFFER_LIMIT + 1)) + client = Client(BASE_URL, opener=opener) + + with self.assertRaises(ResponseTooLargeError) as raised: + client.meters.query_csv(METER_ID, MeterQueryRequest()) + + self.assertEqual(raised.exception.limit, BUFFER_LIMIT) + + def test_malformed_error_response_is_bounded(self) -> None: + error_body = b"x" * (ERROR_LIMIT + 1) + opener = Mock() + opener.open.side_effect = HTTPError(BASE_URL, 500, "error", {}, io.BytesIO(error_body)) + client = Client(BASE_URL, opener=opener) + + with self.assertRaises(APIError) as raised: + client.meters.get(METER_ID) + + self.assertEqual(raised.exception.status_code, 500) + self.assertEqual(len(raised.exception.raw_body), ERROR_LIMIT) + self.assertTrue(raised.exception.raw_body_truncated) + + +class AsyncClientTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + _Handler.requests.clear() + _Handler.fail_counts.clear() + + async def test_async_meter_and_plan_addon_operations(self) -> None: + async with AsyncClient(BASE_URL, token="om_test", trust_env=False) as client: + create_meter = CreateMeterRequest( + name="Tokens Total", + key="tokens_total", + aggregation="sum", + event_type="prompt", + ) + self.assertEqual((await client.meters.create(create_meter)).key, "tokens_total") + meter = await client.meters.get(METER_ID) + self.assertEqual(meter.id, METER_ID) + self.assertEqual( + (await client.meters.update(METER_ID, UpdateMeterRequest(name="Renamed"))).name, + "Renamed", + ) + + meter_ids = [ + meter.id + async for meter in client.meters.list_all(MeterListParams(page=PageParams(size=1))) + ] + self.assertEqual(meter_ids, [METER_ID, SECOND_METER_ID]) + + query = MeterQueryRequest() + self.assertEqual( + (await client.meters.query(METER_ID, query)).data[0].value, Decimal("12.50") + ) + self.assertTrue((await client.meters.query_csv(METER_ID, query)).startswith(b"from,to")) + async with client.meters.query_csv_stream(METER_ID, query) as stream: + self.assertEqual( + await stream.read(), + b"from,to,value\n2026-07-14T09:00:00Z,2026-07-14T10:00:00Z,12\n", + ) + + async with client.meters.query_csv_stream(METER_ID, query) as stream: + self.assertEqual(await stream.read(5), b"from,") + self.assertEqual( + await stream.read(), + b"to,value\n2026-07-14T09:00:00Z,2026-07-14T10:00:00Z,12\n", + ) + + async with client.meters.query_csv_stream(METER_ID, query) as stream: + lines = [line async for line in stream] + self.assertEqual( + lines, + [ + b"from,to,value\n", + b"2026-07-14T09:00:00Z,2026-07-14T10:00:00Z,12\n", + ], + ) + + async with client.meters.query_csv_stream(METER_ID, query) as stream: + self.assertEqual(await stream.read(5), b"from,") + response = stream._response + self.assertFalse(response.is_closed) + self.assertTrue(response.is_closed) + + plan_addon = await client.plan_addons.get(PLAN_ID, PLAN_ADDON_ID) + self.assertEqual(plan_addon.id, PLAN_ADDON_ID) + create_plan_addon = CreatePlanAddonRequest( + name="Pro add-on", + addon=AddonReference(id=ADDON_ID), + from_plan_phase="default", + ) + self.assertEqual( + (await client.plan_addons.create(PLAN_ID, create_plan_addon)).addon.id, + ADDON_ID, + ) + update_plan_addon = UpsertPlanAddonRequest( + name="Renamed", + from_plan_phase="default", + ) + self.assertEqual( + ( + await client.plan_addons.update( + PLAN_ID, + PLAN_ADDON_ID, + update_plan_addon, + ) + ).name, + "Renamed", + ) + plan_addons = [item.id async for item in client.plan_addons.list_all(PLAN_ID)] + self.assertEqual(plan_addons, [PLAN_ADDON_ID]) + await client.plan_addons.delete(PLAN_ID, PLAN_ADDON_ID) + await client.meters.delete(METER_ID) + + def test_trust_env_uses_http_client_default_and_allows_override(self) -> None: + with patch("openmeter._transport_async.httpx.AsyncClient") as constructor: + AsyncClient(BASE_URL) + constructor.assert_called_once_with(timeout=30.0, trust_env=True) + + with patch("openmeter._transport_async.httpx.AsyncClient") as constructor: + AsyncClient(BASE_URL, trust_env=False) + constructor.assert_called_once_with(timeout=30.0, trust_env=False) + + async def test_async_calls_do_not_block_other_tasks(self) -> None: + response_gate = threading.Event() + marker: list[str] = [] + + async def mark() -> None: + await asyncio.sleep(0) + response_gate.set() + marker.append("ran") + + _Handler.response_gate = response_gate + try: + with patch("asyncio.to_thread", side_effect=AssertionError("thread offload used")): + async with AsyncClient(BASE_URL, trust_env=False) as client: + await asyncio.gather(client.meters.get(METER_ID), mark()) + finally: + _Handler.response_gate = None + + self.assertEqual(marker, ["ran"]) + + async def test_injected_http_client_remains_caller_owned(self) -> None: + def respond(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_meter()) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + try: + async with AsyncClient(BASE_URL, client=http_client) as client: + self.assertEqual((await client.meters.get(METER_ID)).id, METER_ID) + + self.assertFalse(http_client.is_closed) + finally: + await http_client.aclose() + + async def test_redirect_response_raises_api_error(self) -> None: + requests: list[httpx.Request] = [] + + def redirect(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path.endswith(METER_ID): + return httpx.Response( + 302, + headers={"location": f"{BASE_URL}/openmeter/meters/{SECOND_METER_ID}"}, + ) + + return httpx.Response(200, json=_meter(SECOND_METER_ID)) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(redirect), + follow_redirects=True, + ) + try: + async with AsyncClient(BASE_URL, client=http_client) as client: + with self.assertRaises(APIError) as raised: + await client.meters.get(METER_ID) + + self.assertEqual(raised.exception.status_code, 302) + self.assertEqual(len(requests), 1) + finally: + await http_client.aclose() + + async def test_token_falls_back_to_environment(self) -> None: + with patch.dict(os.environ, {"OPENMETER_TOKEN": "om_env"}): + async with AsyncClient(BASE_URL, trust_env=False) as client: + await client.meters.get(METER_ID) + + self.assertEqual(_Handler.requests[-1]["headers"]["Authorization"], "Bearer om_env") + + async def test_buffered_success_response_is_bounded(self) -> None: + def respond(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"x" * (BUFFER_LIMIT + 1), request=request) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + try: + async with AsyncClient(BASE_URL, client=http_client) as client: + with self.assertRaises(ResponseTooLargeError) as raised: + await client.meters.query_csv(METER_ID, MeterQueryRequest()) + + self.assertEqual(raised.exception.limit, BUFFER_LIMIT) + finally: + await http_client.aclose() + + async def test_malformed_error_response_is_bounded(self) -> None: + def respond(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, content=b"x" * (ERROR_LIMIT + 1), request=request) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + try: + async with AsyncClient(BASE_URL, client=http_client) as client: + with self.assertRaises(APIError) as raised: + await client.meters.get(METER_ID) + + self.assertEqual(raised.exception.status_code, 500) + self.assertEqual(len(raised.exception.raw_body), ERROR_LIMIT) + self.assertTrue(raised.exception.raw_body_truncated) + finally: + await http_client.aclose() + + +class RetryTests(unittest.TestCase): + def setUp(self) -> None: + _Handler.requests.clear() + _Handler.fail_counts.clear() + self.client = Client(BASE_URL, token="om_test") + self.meter_path = f"/openmeter/meters/{METER_ID}" + + def test_idempotent_get_retries_transient_5xx_then_succeeds(self) -> None: + _Handler.fail_counts[self.meter_path] = 2 + with patch("openmeter._transport._RETRY_BACKOFF_SECONDS", 0.001): + meter = self.client.meters.get(METER_ID) + + self.assertEqual(meter.id, METER_ID) + self.assertEqual(len(_Handler.requests), 3) + + def test_idempotent_get_gives_up_after_max_retries(self) -> None: + _Handler.fail_counts[self.meter_path] = 10 + with patch("openmeter._transport._RETRY_BACKOFF_SECONDS", 0.001): + with self.assertRaises(APIError) as raised: + self.client.meters.get(METER_ID) + + self.assertEqual(raised.exception.status_code, 503) + self.assertEqual(len(_Handler.requests), 3) + + def test_non_idempotent_post_is_not_retried_on_5xx(self) -> None: + _Handler.fail_counts["/openmeter/meters"] = 5 + request = CreateMeterRequest( + name="Tokens Total", key="tokens_total", aggregation="sum", event_type="prompt" + ) + with patch("openmeter._transport._RETRY_BACKOFF_SECONDS", 0.001): + with self.assertRaises(APIError) as raised: + self.client.meters.create(request) + + self.assertEqual(raised.exception.status_code, 503) + self.assertEqual(len(_Handler.requests), 1) + + def test_read_only_post_query_retries_transient_5xx_then_succeeds(self) -> None: + _Handler.fail_counts[f"{self.meter_path}/query"] = 2 + with patch("openmeter._transport._RETRY_BACKOFF_SECONDS", 0.001): + result = self.client.meters.query(METER_ID, MeterQueryRequest()) + + self.assertEqual(result.data[0].value, Decimal("12.50")) + self.assertEqual(len(_Handler.requests), 3) + + def test_idempotent_get_retries_connection_failures(self) -> None: + opener = Mock() + opener.open.side_effect = [ + URLError("temporarily unavailable"), + URLError("temporarily unavailable"), + io.BytesIO(json.dumps(_meter()).encode()), + ] + client = Client(BASE_URL, opener=opener) + + with patch("openmeter._transport._RETRY_BACKOFF_SECONDS", 0.001): + meter = client.meters.get(METER_ID) + + self.assertEqual(meter.id, METER_ID) + self.assertEqual(opener.open.call_count, 3) + + def test_non_idempotent_post_does_not_retry_connection_failure(self) -> None: + opener = Mock() + opener.open.side_effect = URLError("temporarily unavailable") + client = Client(BASE_URL, opener=opener) + request = CreateMeterRequest( + name="Tokens Total", key="tokens_total", aggregation="sum", event_type="prompt" + ) + + with self.assertRaises(TransportError): + client.meters.create(request) + + self.assertEqual(opener.open.call_count, 1) + + +class AsyncRetryTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + _Handler.requests.clear() + _Handler.fail_counts.clear() + self.meter_path = f"/openmeter/meters/{METER_ID}" + + async def test_idempotent_get_retries_transient_5xx_then_succeeds(self) -> None: + _Handler.fail_counts[self.meter_path] = 2 + with patch("openmeter._transport_async._RETRY_BACKOFF_SECONDS", 0.001): + async with AsyncClient(BASE_URL, token="om_test", trust_env=False) as client: + meter = await client.meters.get(METER_ID) + + self.assertEqual(meter.id, METER_ID) + self.assertEqual(len(_Handler.requests), 3) + + async def test_idempotent_get_gives_up_after_max_retries(self) -> None: + _Handler.fail_counts[self.meter_path] = 10 + with patch("openmeter._transport_async._RETRY_BACKOFF_SECONDS", 0.001): + async with AsyncClient(BASE_URL, token="om_test", trust_env=False) as client: + with self.assertRaises(APIError) as raised: + await client.meters.get(METER_ID) + + self.assertEqual(raised.exception.status_code, 503) + self.assertEqual(len(_Handler.requests), 3) + + async def test_non_idempotent_post_is_not_retried_on_5xx(self) -> None: + _Handler.fail_counts["/openmeter/meters"] = 5 + request = CreateMeterRequest( + name="Tokens Total", key="tokens_total", aggregation="sum", event_type="prompt" + ) + with patch("openmeter._transport_async._RETRY_BACKOFF_SECONDS", 0.001): + async with AsyncClient(BASE_URL, token="om_test", trust_env=False) as client: + with self.assertRaises(APIError) as raised: + await client.meters.create(request) + + self.assertEqual(raised.exception.status_code, 503) + self.assertEqual(len(_Handler.requests), 1) + + async def test_read_only_post_query_retries_transient_5xx_then_succeeds(self) -> None: + _Handler.fail_counts[f"{self.meter_path}/query"] = 2 + with patch("openmeter._transport_async._RETRY_BACKOFF_SECONDS", 0.001): + async with AsyncClient(BASE_URL, token="om_test", trust_env=False) as client: + result = await client.meters.query(METER_ID, MeterQueryRequest()) + + self.assertEqual(result.data[0].value, Decimal("12.50")) + self.assertEqual(len(_Handler.requests), 3) + + async def test_idempotent_get_retries_connection_failures(self) -> None: + attempts = 0 + + def respond(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise httpx.ConnectError("temporarily unavailable", request=request) + + return httpx.Response(200, json=_meter(), request=request) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + try: + with patch("openmeter._transport_async._RETRY_BACKOFF_SECONDS", 0.001): + async with AsyncClient(BASE_URL, client=http_client) as client: + meter = await client.meters.get(METER_ID) + + self.assertEqual(meter.id, METER_ID) + self.assertEqual(attempts, 3) + finally: + await http_client.aclose() + + async def test_non_idempotent_post_does_not_retry_connection_failure(self) -> None: + attempts = 0 + + def fail(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ConnectError("temporarily unavailable", request=request) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(fail)) + request = CreateMeterRequest( + name="Tokens Total", key="tokens_total", aggregation="sum", event_type="prompt" + ) + try: + async with AsyncClient(BASE_URL, client=http_client) as client: + with self.assertRaises(TransportError): + await client.meters.create(request) + + self.assertEqual(attempts, 1) + finally: + await http_client.aclose() + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/python/openmeter/uv.lock b/sdk/python/openmeter/uv.lock new file mode 100644 index 0000000000..59db9450b4 --- /dev/null +++ b/sdk/python/openmeter/uv.lock @@ -0,0 +1,584 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "build" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/ec/bf5ae0a7e5ab57abe8aabdd0759c971883895d1a20c49ae99f8146840c3c/build-1.4.4.tar.gz", hash = "sha256:f832ae053061f3fb524af812dc94b8b84bac6880cd587630e3b5d91a6a9c1703", size = 89220, upload-time = "2026-04-22T20:53:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/88/6764e7a109dd84294850741501145da90d13cdeac9d4e614929464a37420/build-1.4.4-py3-none-any.whl", hash = "sha256:8c3f48a6090b39edec1a273d2d57949aaf13723b01e02f9d518396887519f64d", size = 25921, upload-time = "2026-04-22T20:53:43.251Z" }, +] + +[[package]] +name = "build" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/21/6ec54248b4d0d51f12f3ca4aa77a128077d747a5db86cb5a2fcd9aedecbd/build-1.5.1.tar.gz", hash = "sha256:94e17f1db803ab22f46049376c44c8437c52090f0dfdf1adc43df56542d644fb", size = 112439, upload-time = "2026-07-09T06:21:59.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/f7/2c3f99ff9282f1c1ec9f6298f2c03034658a0901eeacb3b3501cb488574c/build-1.5.1-py3-none-any.whl", hash = "sha256:f1a58fe2e5af5b0238a07b9e70207492c79ddebbdb1ad954fc86d62a56be3e0d", size = 31195, upload-time = "2026-07-09T06:21:58.551Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[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 = "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 = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openmeter" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] + +[package.optional-dependencies] +async = [ + { name = "httpx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "build", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "build", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "flake8" }, + { name = "httpx" }, + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'async'", specifier = ">=0.28.1,<1" }, + { name = "pydantic", specifier = ">=2.10,<3" }, +] +provides-extras = ["async"] + +[package.metadata.requires-dev] +dev = [ + { name = "build", specifier = ">=1.2,<2" }, + { name = "flake8", specifier = ">=7,<8" }, + { name = "httpx", specifier = ">=0.28.1,<1" }, + { name = "pyright", specifier = ">=1.1.400,<2" }, + { name = "ruff", specifier = ">=0.14,<1" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/5d/00/13a0c039569d1e583779ee1b8d7df6bfe275a0db83fcae14f01d6856c16e/pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae", size = 2115337, upload-time = "2026-05-06T13:38:37.741Z" }, + { url = "https://files.pythonhosted.org/packages/41/60/e70fa1ee03e243bdfd4b1fddf1e1f2a8fba681df3034b51b9376c0fb5bf5/pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201", size = 1957976, upload-time = "2026-05-06T13:37:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/11/9a/78fb5f2ea849f767ea802de8b4e8f5a0c4a48ddbe4bc66bd19ac2f55a01c/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0", size = 1979390, upload-time = "2026-05-06T13:36:52.419Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7d/3acfdcd000bad9735de0430a88355948469781f62cb841fd63e8a307e80e/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15", size = 2043263, upload-time = "2026-05-06T13:39:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/1325e5a8d7f9697416481c7f7c1c304738d6b961a7fd1ea0f054ce0f14fb/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76", size = 2225708, upload-time = "2026-05-06T13:40:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9ec8c38f33b26db0b612cb7fd165bb0a370773710432a2a74fa31287b430/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49", size = 2288494, upload-time = "2026-05-06T13:38:00.091Z" }, + { url = "https://files.pythonhosted.org/packages/65/05/497446a9586d1b2d24ee25ebe208beb15388f1875d783e1e014055d150ac/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928", size = 2095629, upload-time = "2026-05-06T13:38:23.632Z" }, + { url = "https://files.pythonhosted.org/packages/93/d9/cd5fa98f9d94f9294c15459396c8a2383c164469e679ac178d6d42cfee6b/pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066", size = 2119309, upload-time = "2026-05-06T13:39:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/20/1b/64cec655451ddbf3976df5dc9706b240df4fdaebdeebeadd4f59a8dab926/pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6", size = 2170216, upload-time = "2026-05-06T13:39:14.561Z" }, + { url = "https://files.pythonhosted.org/packages/2a/21/fe9f039138c9ea3be10ccdb6ec490acb54dcbef5a5e96dbdf1411f82b929/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9", size = 2186726, upload-time = "2026-05-06T13:37:51.597Z" }, + { url = "https://files.pythonhosted.org/packages/44/cb/19ca0da64821d1aefcef65f253aa9ecbdd0dde360f607d0f9b3d95db2b4e/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29", size = 2320400, upload-time = "2026-05-06T13:39:36.29Z" }, + { url = "https://files.pythonhosted.org/packages/cd/14/fe3fbf6e845bf2080dc2f282d75085ddf79d037b35634ecde68f33c217b4/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9", size = 2363318, upload-time = "2026-05-06T13:38:53.039Z" }, + { url = "https://files.pythonhosted.org/packages/62/88/60b110889507a426eecf626f7536566cb290ada71147eff49b6e2724ca62/pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1", size = 1988880, upload-time = "2026-05-06T13:39:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d6/8ede2f98f17e1e4e127d37be0eced4eee931a511c62cd68af50e1b25bfa9/pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac", size = 2079257, upload-time = "2026-05-06T13:39:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[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 = "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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]