diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..baf02db --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync + - run: uv run pytest tests/ -v + - run: uv run ruff check . + - run: uv run ruff format --check . diff --git a/.gitignore b/.gitignore index 51304c5..ed83127 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,14 @@ build/ *.swp *.swo +# AI agents +.kiro/ +.cursor/ +.windsurf/ +.aider* +.cline/ +.roo/ + # OS .DS_Store diff --git a/README.md b/README.md index 086ca0a..a350202 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Thin wrapper around the [MusicBrainz Web Service v2](https://musicbrainz.org/doc ## Requirements -- Python 3.12+ +- Python 3.10+ - [uv](https://docs.astral.sh/uv/) ## Installation @@ -107,6 +107,52 @@ plain = annotation_to_text(artist.annotation) md = annotation_to_markdown(artist.annotation) ``` +### Cover Art Archive + +```python +from musicbrainzpy import SyncCoverArtClient + +with SyncCoverArtClient("myapp", "1.0", "me@example.com") as caa: + # List images for a release + image_list = caa.get_image_list(release_mbid) + for img in image_list.images: + print(f" {img.id}: {img.types} ({img.front=})") + + # Download front cover (full-size or thumbnail) + data = caa.get_front(release_mbid) + data = caa.get_front(release_mbid, size=500) # 250, 500, or 1200 + + # Get image metadata without downloading + info = caa.image_info(release_mbid, "front") + print(f" {info['content_type']}, {info['content_length']} bytes") +``` + +Async version: `CoverArtClient` with the same methods (all `await`-able). + +### Environment variables + +Client defaults can be set via environment variables (explicit constructor args always win): + +| Variable | Overrides | +|---|---| +| `MUSICBRAINZPY_APP` | `app_name` | +| `MUSICBRAINZPY_VERSION` | `app_version` | +| `MUSICBRAINZPY_CONTACT` | `app_contact` | +| `MUSICBRAINZPY_BASE_URL` | `base_url` | +| `MUSICBRAINZPY_USERNAME` | `username` | +| `MUSICBRAINZPY_PASSWORD` | `password` | + +```bash +export MUSICBRAINZPY_APP=myapp +export MUSICBRAINZPY_VERSION=1.0 +export MUSICBRAINZPY_CONTACT=me@example.com +``` + +```python +# No args needed when env vars are set +client = SyncMusicBrainzClient() +``` + See [docs/oauth2.md](docs/oauth2.md) for the full OAuth2 guide with PKCE, token refresh, and examples. Coming from **musicbrainzngs**? See the [migration guide](docs/migrating-from-ngs.md). diff --git a/docs/api-reference.md b/docs/api-reference.md index 5c4ec8c..b6c6281 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -13,6 +13,17 @@ All requests set the `Accept: application/json` header. - Max 1 request/second per client - Must set a meaningful `User-Agent` header: `AppName/Version ( contact-url-or-email )` - Violators get IP-blocked +- Server returns HTTP 503 (or 429) when rate limit is exceeded + +## Retry Behavior + +All clients automatically retry on transient failures: + +- **Retried**: `httpx.TransportError` (connection errors, timeouts), HTTP 429, HTTP 503 +- **Not retried**: HTTP 400, 401, 404, or any other client/server error +- **Default**: 3 retries with exponential backoff (1s, 2s, 4s) +- **Retry-After**: respected when the server includes the header +- **Configuration**: `max_retries=` and `retry_base_delay=` constructor params; set `max_retries=0` to disable ## Lookup @@ -124,3 +135,32 @@ Annotations use MusicBrainz wiki markup. The `annotation` module provides conver - `annotation_to_markdown(markup)` — convert to Markdown See https://musicbrainz.org/doc/Annotation#Wiki_formatting for the markup spec. + +## Cover Art Archive + +Separate API at `https://coverartarchive.org/`. No authentication or rate limiting required. + +| Endpoint | Returns | +|---|---| +| `GET /release//` | JSON image listing | +| `GET /release-group//` | JSON image listing | +| `GET /release//front` | Front cover image (binary), 307 redirect to archive.org | +| `GET /release//back` | Back cover image (binary) | +| `GET /release//` | Specific image (binary) | + +Thumbnail sizes: append `-250`, `-500`, or `-1200` to the image path (e.g. `/release//front-500`). + +Use `HEAD` requests to get `Content-Type` and `Content-Length` without downloading the image. + +## Environment Variables + +Client defaults can be set via environment variables. Explicit constructor arguments always take precedence. + +| Variable | Overrides | +|---|---| +| `MUSICBRAINZPY_APP` | `app_name` | +| `MUSICBRAINZPY_VERSION` | `app_version` | +| `MUSICBRAINZPY_CONTACT` | `app_contact` | +| `MUSICBRAINZPY_BASE_URL` | `base_url` | +| `MUSICBRAINZPY_USERNAME` | `username` | +| `MUSICBRAINZPY_PASSWORD` | `password` | diff --git a/docs/architecture.md b/docs/architecture.md index ad671b3..45e73b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,11 +63,27 @@ The base URL is configurable via the client constructor to support mirrors and l - Max 1 request per second (API requirement) - Implemented as a simple timestamp check with `asyncio.sleep()` +## Retry + +- Transient failures (`httpx.TransportError`, HTTP 429/503) are retried with exponential backoff +- Default: 3 retries with 1s base delay (1s, 2s, 4s) +- Respects `Retry-After` header when present +- Configurable via `max_retries` and `retry_base_delay` constructor params +- Permanent errors (400, 401, 404) are never retried + ## Authentication - Digest auth (via `httpx.DigestAuth`) — current standard - OAuth2 — for user-scoped operations (tags, ratings, collections) +## Cover Art Archive + +Separate client (`CoverArtClient` / `SyncCoverArtClient`) for the Cover Art Archive API at `coverartarchive.org`. Different host, no auth, no rate limiting. Supports image listings, binary downloads, and HEAD-based metadata queries. + +## Environment Variables + +Client defaults can be set via `MUSICBRAINZPY_`-prefixed environment variables. Explicit constructor arguments always take precedence. Supported: `APP`, `VERSION`, `CONTACT`, `BASE_URL`, `USERNAME`, `PASSWORD`. + ## Module Layout ``` @@ -75,6 +91,7 @@ musicbrainzpy/ ├── __init__.py # Public API re-exports ├── client.py # MusicBrainzClient (async) ├── sync_client.py # SyncMusicBrainzClient (sync wrapper) +├── coverart.py # CoverArtClient / SyncCoverArtClient (Cover Art Archive) ├── models/ # Pydantic models per entity type │ ├── __init__.py # Re-exports all models │ ├── common.py # Shared: ArtistCredit, LifeSpan, Tag, Genre, etc. @@ -90,19 +107,25 @@ musicbrainzpy/ │ ├── instrument.py │ ├── series.py │ ├── genre.py -│ └── url.py +│ ├── url.py +│ ├── annotation.py # Annotation search result model +│ ├── collection.py # Collection model +│ └── coverart.py # CoverArtImage, CoverArtImageList, Thumbnails ├── enums.py # EntityType, ReleaseStatus, ReleaseGroupType, etc. ├── auth.py # OAuth2 flow helpers ├── exceptions.py # MusicBrainzError, NotFoundError, RateLimitedError, etc. ├── annotation.py # Wiki markup → plain text / Markdown converter ├── _xml.py # XML body builders for submissions ├── _ratelimit.py # Async/sync rate limiter +├── _retry.py # Retry with exponential backoff for transient failures └── py.typed # PEP 561 marker tests/ ├── conftest.py # respx fixtures, sample JSON responses ├── test_client.py # Lookup/browse/search integration tests ├── test_sync_client.py # Sync wrapper tests ├── test_models.py # Deserialization round-trip tests +├── test_coverart.py # Cover Art Archive client tests +├── test_retry.py # Retry logic tests ├── test_xml.py # XML body builder tests ├── test_oauth.py # OAuth2 flow tests └── test_annotation.py # Annotation converter tests diff --git a/docs/migrating-from-ngs.md b/docs/migrating-from-ngs.md index f29d740..355b83c 100644 --- a/docs/migrating-from-ngs.md +++ b/docs/migrating-from-ngs.md @@ -10,7 +10,7 @@ This guide helps you move from [python-musicbrainzngs](https://github.com/alasta | Architecture | Module-level global state | Instance-based clients | | Async support | No | Yes (+ sync client) | | Return types | Plain dicts | Pydantic models or dicts | -| Python | 2.7+ / 3.x | 3.12+ | +| Python | 2.7+ / 3.x | 3.10+ | | HTTP library | urllib | httpx | | Auth | Digest only | Digest + OAuth2 | @@ -255,6 +255,43 @@ await client.collection_add("myapp-1.0", collection_mbid, "releases", [release_m await client.collection_remove("myapp-1.0", collection_mbid, "releases", [release_mbid]) ``` +## Cover Art + +musicbrainzpy provides a separate client for the Cover Art Archive, imported from `musicbrainzpy.coverart` (or the top-level package). + +```python +# musicbrainzngs +images = musicbrainzngs.get_image_list("76df3287-6cda-33eb-8e9a-044b5e15ffdd") +front = musicbrainzngs.get_image_front("76df3287-6cda-33eb-8e9a-044b5e15ffdd", size="500") +back = musicbrainzngs.get_image_back("76df3287-6cda-33eb-8e9a-044b5e15ffdd") +img = musicbrainzngs.get_image("76df3287-6cda-33eb-8e9a-044b5e15ffdd", "829521842", size="250") +rg_images = musicbrainzngs.get_release_group_image_list("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932") +rg_front = musicbrainzngs.get_release_group_image_front("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932") + +# musicbrainzpy (async) +from musicbrainzpy import CoverArtClient + +async with CoverArtClient("myapp", "1.0", "me@example.com") as caa: + images = await caa.get_image_list("76df3287-6cda-33eb-8e9a-044b5e15ffdd") + front = await caa.get_front("76df3287-6cda-33eb-8e9a-044b5e15ffdd", size=500) + back = await caa.get_back("76df3287-6cda-33eb-8e9a-044b5e15ffdd") + img = await caa.get_image("76df3287-6cda-33eb-8e9a-044b5e15ffdd", "829521842", size=250) + rg_images = await caa.get_release_group_image_list("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932") + rg_front = await caa.get_release_group_front("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932") + +# musicbrainzpy (sync) +from musicbrainzpy import SyncCoverArtClient + +with SyncCoverArtClient("myapp", "1.0", "me@example.com") as caa: + images = caa.get_image_list("76df3287-6cda-33eb-8e9a-044b5e15ffdd") + front = caa.get_front("76df3287-6cda-33eb-8e9a-044b5e15ffdd", size=500) +``` + +Key differences: +- Separate client (`CoverArtClient` / `SyncCoverArtClient`) instead of module-level functions +- `size` is an int (250, 500, 1200) instead of a string +- Image listings return typed `CoverArtImageList` with `CoverArtImage` models instead of plain dicts + ## Exceptions | musicbrainzngs | musicbrainzpy | @@ -284,10 +321,7 @@ except MusicBrainzError as e: print(e) ``` -## Features not yet in musicbrainzpy - -- **Cover Art Archive** — `get_image`, `get_image_front`, `get_image_back`, `get_image_list`, `get_release_group_image_list`, `get_release_group_image_front` -- **Custom response parsers** — `set_parser()`, `set_format()` +> **Note:** musicbrainzngs `set_parser()` and `set_format()` have no equivalent — musicbrainzpy always uses the JSON API natively and returns either Pydantic models (typed methods) or plain dicts (raw methods), so custom XML parsing is unnecessary. ## Quick reference @@ -339,3 +373,9 @@ except MusicBrainzError as e: | `remove_releases_from_collection(coll, ids)` | `await client.collection_remove(client_id, coll, "releases", ids)` | | `get_collections()` | `client.get_collections()` | | `get_releases_in_collection(coll, limit)` | `client.browse_typed("release", linked_type="collection", linked_id=coll, limit=limit)` | +| `get_image_list(release_id)` | `caa.get_image_list(release_id)` | +| `get_release_group_image_list(rg_id)` | `caa.get_release_group_image_list(rg_id)` | +| `get_image(mbid, cover_id, size)` | `caa.get_image(mbid, cover_id, size=size)` | +| `get_image_front(release_id, size)` | `caa.get_front(release_id, size=size)` | +| `get_image_back(release_id, size)` | `caa.get_back(release_id, size=size)` | +| `get_release_group_image_front(rg_id, size)` | `caa.get_release_group_front(rg_id, size=size)` | diff --git a/examples/cover_art.py b/examples/cover_art.py new file mode 100644 index 0000000..cc3e062 --- /dev/null +++ b/examples/cover_art.py @@ -0,0 +1,48 @@ +"""Retrieve cover art for a release (Sufjan Stevens — Carrie & Lowell, 2xCD).""" + +from __future__ import annotations + +import asyncio +import tempfile +from pathlib import Path + +from musicbrainzpy import CoverArtClient, MusicBrainzClient + +RELEASE_ID = "de8231d3-b745-42fa-9e0c-7ffb6d3d9608" + + +async def main() -> None: + async with MusicBrainzClient("musicbrainzpy-examples", "0.1.0", "you@example.com") as mb: + release = await mb.lookup_typed("release", RELEASE_ID, includes=["artist-credits"]) + print(f"Release: {release.title} (id: {release.id})") # type: ignore[attr-defined] + + async with CoverArtClient("musicbrainzpy-examples", "0.1.0", "you@example.com") as caa: + # Download the 250px front cover + front = await caa.get_front(RELEASE_ID, size=250) + out = Path(tempfile.gettempdir()) / "front-250.jpg" + out.write_bytes(front) + print(f"\nFront cover saved to {out.resolve()} ({len(front)} bytes)") + + # List all available cover art images with full-size metadata + listing = await caa.get_image_list(RELEASE_ID) + print(f"\nAvailable cover art ({len(listing.images)} images):\n") + for img in listing.images: + info = await caa.image_info(RELEASE_ID, str(img.id)) + content_type = info["content_type"] or "?" + size_bytes = info["content_length"] + size_str = f"{int(size_bytes) / 1024:.0f} KB" if size_bytes else "?" + thumbs = [] + if img.thumbnails.t250: + thumbs.append("250px") + if img.thumbnails.t500: + thumbs.append("500px") + if img.thumbnails.t1200: + thumbs.append("1200px") + types = ", ".join(img.types) or "untyped" + comment = f' "{img.comment}"' if img.comment else "" + print(f" [{img.id}] {types}") + print(f" {content_type}, {size_str}, thumbnails: {', '.join(thumbs)}{comment}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/digest_auth.py b/examples/digest_auth.py index 3be4b30..672981f 100644 --- a/examples/digest_auth.py +++ b/examples/digest_auth.py @@ -32,7 +32,7 @@ async def main() -> None: if not result.items: print(" (empty)") for item in result.items: - label = getattr(item, "title", None) or getattr(item, "name", None) or item.id + label = getattr(item, "title", None) or getattr(item, "name", None) or str(item) print(f" - {label}") if result.count > 5: print(f" ... and {result.count - 5} more") diff --git a/musicbrainzpy/__init__.py b/musicbrainzpy/__init__.py index 533d32a..ce71235 100644 --- a/musicbrainzpy/__init__.py +++ b/musicbrainzpy/__init__.py @@ -5,6 +5,7 @@ from musicbrainzpy.annotation import annotation_to_markdown, annotation_to_text from musicbrainzpy.auth import OAuthHandler, OAuthToken, build_authorization_url, generate_pkce from musicbrainzpy.client import BrowseResult, MusicBrainzClient, SearchResult +from musicbrainzpy.coverart import CoverArtClient, SyncCoverArtClient from musicbrainzpy.enums import OAuthScope from musicbrainzpy.exceptions import ( AuthenticationError, @@ -18,6 +19,7 @@ __all__ = [ "AuthenticationError", "BrowseResult", + "CoverArtClient", "InvalidRequestError", "MusicBrainzClient", "MusicBrainzError", @@ -27,6 +29,7 @@ "OAuthToken", "RateLimitedError", "SearchResult", + "SyncCoverArtClient", "SyncMusicBrainzClient", "annotation_to_markdown", "annotation_to_text", diff --git a/musicbrainzpy/_retry.py b/musicbrainzpy/_retry.py new file mode 100644 index 0000000..c99eb55 --- /dev/null +++ b/musicbrainzpy/_retry.py @@ -0,0 +1,72 @@ +"""Retry helpers for transient HTTP failures.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from typing import TypeVar + +import httpx + +from musicbrainzpy.exceptions import RateLimitedError + +logger = logging.getLogger("musicbrainzpy") + +#: Defaults +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BASE_DELAY = 1.0 + + +def _retry_delay(exc: Exception, attempt: int, base_delay: float) -> float: + """Compute delay: use Retry-After if available, otherwise exponential backoff.""" + if isinstance(exc, RateLimitedError) and exc.retry_after is not None: + return exc.retry_after + return base_delay * (2**attempt) + + +def _is_retryable(exc: Exception) -> bool: + """Return True for transient errors worth retrying.""" + return isinstance(exc, (httpx.TransportError, RateLimitedError)) + + +_T = TypeVar("_T") + + +async def async_retry( + func: Callable[[], Awaitable[_T]], + *, + max_retries: int = DEFAULT_MAX_RETRIES, + base_delay: float = DEFAULT_BASE_DELAY, +) -> _T: + """Call *func* with retries on transient failures.""" + for attempt in range(max_retries + 1): + try: + return await func() + except Exception as exc: + if not _is_retryable(exc) or attempt == max_retries: + raise + delay = _retry_delay(exc, attempt, base_delay) + logger.debug("Retry %d/%d after %.1fs: %s", attempt + 1, max_retries, delay, exc) + await asyncio.sleep(delay) + raise AssertionError("unreachable") # pragma: no cover + + +def sync_retry( + func: Callable[[], _T], + *, + max_retries: int = DEFAULT_MAX_RETRIES, + base_delay: float = DEFAULT_BASE_DELAY, +) -> _T: + """Call *func* with retries on transient failures (sync version).""" + for attempt in range(max_retries + 1): + try: + return func() + except Exception as exc: + if not _is_retryable(exc) or attempt == max_retries: + raise + delay = _retry_delay(exc, attempt, base_delay) + logger.debug("Retry %d/%d after %.1fs: %s", attempt + 1, max_retries, delay, exc) + time.sleep(delay) + raise AssertionError("unreachable") # pragma: no cover diff --git a/musicbrainzpy/client.py b/musicbrainzpy/client.py index be30192..a35c6d1 100644 --- a/musicbrainzpy/client.py +++ b/musicbrainzpy/client.py @@ -6,13 +6,16 @@ from __future__ import annotations +import contextlib +import os from collections.abc import Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, Generic, TypeVar import httpx from musicbrainzpy._ratelimit import RateLimiter +from musicbrainzpy._retry import DEFAULT_BASE_DELAY, DEFAULT_MAX_RETRIES, async_retry from musicbrainzpy._xml import build_barcode_xml, build_isrc_xml, build_rating_xml, build_tag_xml from musicbrainzpy.auth import OAuthHandler, make_digest_auth from musicbrainzpy.exceptions import ( @@ -43,11 +46,15 @@ DEFAULT_BASE_URL = "https://musicbrainz.org/ws/2/" +#: Environment variable prefix for configuration overrides. +_ENV_PREFIX = "MUSICBRAINZPY_" + #: Maps HTTP status codes to exception classes. _STATUS_EXCEPTIONS: dict[int, type[MusicBrainzError]] = { 400: InvalidRequestError, 401: AuthenticationError, 404: NotFoundError, + 429: RateLimitedError, 503: RateLimitedError, } @@ -85,7 +92,14 @@ def _raise_for_status(response: httpx.Response) -> None: if response.is_success: return exc_class = _STATUS_EXCEPTIONS.get(response.status_code, MusicBrainzError) - raise exc_class(f"HTTP {response.status_code}: {response.text}") + msg = f"HTTP {response.status_code}: {response.text}" + if exc_class is RateLimitedError: + retry_after: float | None = None + if raw := response.headers.get("Retry-After"): + with contextlib.suppress(ValueError): + retry_after = float(raw) + raise RateLimitedError(msg, retry_after=retry_after) + raise exc_class(msg) def _get_entity_info(entity_type: str) -> tuple[type[MBModel], str]: @@ -96,20 +110,23 @@ def _get_entity_info(entity_type: str) -> tuple[type[MBModel], str]: raise ValueError(f"Unknown entity type: {entity_type!r}") from None +_T = TypeVar("_T", bound=MBModel) + + @dataclass -class SearchResult[T: MBModel]: +class SearchResult(Generic[_T]): """Wrapper for search responses.""" - items: list[T] + items: list[_T] count: int offset: int @dataclass -class BrowseResult[T: MBModel]: +class BrowseResult(Generic[_T]): """Wrapper for browse responses.""" - items: list[T] + items: list[_T] count: int offset: int @@ -123,37 +140,56 @@ class MusicBrainzClient: - **OAuth2**: pass an :class:`~musicbrainzpy.auth.OAuthHandler` instance. Args: - app_name: Application name for User-Agent. - app_version: Application version for User-Agent. - app_contact: Contact URL or email for User-Agent. - base_url: API base URL. Defaults to the official endpoint. + app_name: Application name for User-Agent. Falls back to ``MUSICBRAINZPY_APP``. + app_version: Application version for User-Agent. Falls back to ``MUSICBRAINZPY_VERSION``. + app_contact: Contact URL or email for User-Agent. Falls back to ``MUSICBRAINZPY_CONTACT``. + base_url: API base URL. Falls back to ``MUSICBRAINZPY_BASE_URL``, then the official endpoint. rate_limit: Minimum seconds between requests. Set to 0 to disable. - username: MusicBrainz username for digest auth. - password: MusicBrainz password for digest auth. + max_retries: Maximum retries on transient failures. Set to 0 to disable. + retry_base_delay: Base delay in seconds for exponential backoff. + username: MusicBrainz username for digest auth. Falls back to ``MUSICBRAINZPY_USERNAME``. + password: MusicBrainz password for digest auth. Falls back to ``MUSICBRAINZPY_PASSWORD``. oauth: An :class:`~musicbrainzpy.auth.OAuthHandler` for OAuth2 auth. """ def __init__( self, - app_name: str, - app_version: str, - app_contact: str, + app_name: str | None = None, + app_version: str | None = None, + app_contact: str | None = None, *, - base_url: str = DEFAULT_BASE_URL, + base_url: str | None = None, rate_limit: float = 1.0, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base_delay: float = DEFAULT_BASE_DELAY, username: str | None = None, password: str | None = None, oauth: OAuthHandler | None = None, ) -> None: - self._base_url = base_url.rstrip("/") + "/" + _app = app_name or os.environ.get(f"{_ENV_PREFIX}APP") + _ver = app_version or os.environ.get(f"{_ENV_PREFIX}VERSION") + _contact = app_contact or os.environ.get(f"{_ENV_PREFIX}CONTACT") + if not (_app and _ver and _contact): + msg = ( + "app_name, app_version, and app_contact are required " + "(pass them directly or set MUSICBRAINZPY_APP, MUSICBRAINZPY_VERSION, MUSICBRAINZPY_CONTACT)" + ) + raise ValueError(msg) + _base = base_url or os.environ.get(f"{_ENV_PREFIX}BASE_URL") or DEFAULT_BASE_URL + _user = username or os.environ.get(f"{_ENV_PREFIX}USERNAME") + _pass = password or os.environ.get(f"{_ENV_PREFIX}PASSWORD") + self._base_url = _base.rstrip("/") + "/" self._rate_limiter = RateLimiter(interval=rate_limit) - self._digest_auth = make_digest_auth(username, password) if username and password else None + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._digest_auth = make_digest_auth(_user, _pass) if _user and _pass else None self._oauth = oauth self._client = httpx.AsyncClient( headers={ - "User-Agent": _build_user_agent(app_name, app_version, app_contact), + "User-Agent": _build_user_agent(_app, _ver, _contact), "Accept": "application/json", }, + follow_redirects=True, ) async def close(self) -> None: @@ -201,11 +237,15 @@ async def _get(self, path: str, params: Mapping[str, str | list[str]] | None = N Sends authentication credentials if configured. """ auth_kwargs = await self._get_optional_auth_kwargs() - await self._rate_limiter.acquire() - url = self._base_url + path - response = await self._client.get(url, params=params, **auth_kwargs) - _raise_for_status(response) - return response.json() + + async def _do() -> dict[str, Any]: + await self._rate_limiter.acquire() + url = self._base_url + path + response = await self._client.get(url, params=params, **auth_kwargs) + _raise_for_status(response) + return response.json() + + return await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) async def _post(self, path: str, *, params: dict[str, str], body: str) -> None: """Perform a rate-limited authenticated POST with XML body. @@ -219,20 +259,23 @@ async def _post(self, path: str, *, params: dict[str, str], body: str) -> None: AuthenticationError: If no credentials were provided. """ auth_kwargs = await self._get_auth_kwargs() - await self._rate_limiter.acquire() - url = self._base_url + path - headers: dict[str, str] = {"Content-Type": "application/xml; charset=utf-8"} - # Merge bearer token header if using OAuth2 - if "headers" in auth_kwargs: - headers.update(auth_kwargs["headers"]) - response = await self._client.post( - url, - params=params, - content=body, - headers=headers, - auth=auth_kwargs.get("auth"), # type: ignore[arg-type] - ) - _raise_for_status(response) + + async def _do() -> None: + await self._rate_limiter.acquire() + url = self._base_url + path + headers: dict[str, str] = {"Content-Type": "application/xml; charset=utf-8"} + if "headers" in auth_kwargs: + headers.update(auth_kwargs["headers"]) + response = await self._client.post( + url, + params=params, + content=body, + headers=headers, + auth=auth_kwargs.get("auth"), # type: ignore[arg-type] + ) + _raise_for_status(response) + + await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) async def _put(self, path: str, *, params: dict[str, str]) -> None: """Perform a rate-limited authenticated PUT (for collections). @@ -245,10 +288,14 @@ async def _put(self, path: str, *, params: dict[str, str]) -> None: AuthenticationError: If no credentials were provided. """ auth_kwargs = await self._get_auth_kwargs() - await self._rate_limiter.acquire() - url = self._base_url + path - response = await self._client.put(url, params=params, **auth_kwargs) - _raise_for_status(response) + + async def _do() -> None: + await self._rate_limiter.acquire() + url = self._base_url + path + response = await self._client.put(url, params=params, **auth_kwargs) + _raise_for_status(response) + + await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) async def _delete(self, path: str, *, params: dict[str, str]) -> None: """Perform a rate-limited authenticated DELETE (for collections). @@ -261,10 +308,14 @@ async def _delete(self, path: str, *, params: dict[str, str]) -> None: AuthenticationError: If no credentials were provided. """ auth_kwargs = await self._get_auth_kwargs() - await self._rate_limiter.acquire() - url = self._base_url + path - response = await self._client.delete(url, params=params, **auth_kwargs) - _raise_for_status(response) + + async def _do() -> None: + await self._rate_limiter.acquire() + url = self._base_url + path + response = await self._client.delete(url, params=params, **auth_kwargs) + _raise_for_status(response) + + await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) async def lookup(self, entity_type: str, mbid: str, includes: list[str] | None = None) -> dict[str, Any]: """Look up a single entity by MBID. @@ -475,12 +526,16 @@ async def get_collections(self) -> list[Collection]: AuthenticationError: If no credentials were configured. """ auth_kwargs = await self._get_auth_kwargs() - await self._rate_limiter.acquire() - url = self._base_url + "collection" - response = await self._client.get(url, **auth_kwargs) - _raise_for_status(response) - data = response.json() - return [Collection.model_validate(c) for c in data.get("collections", [])] + + async def _do() -> list[Collection]: + await self._rate_limiter.acquire() + url = self._base_url + "collection" + response = await self._client.get(url, **auth_kwargs) + _raise_for_status(response) + data = response.json() + return [Collection.model_validate(c) for c in data.get("collections", [])] + + return await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) # --- Submissions (require auth) --- diff --git a/musicbrainzpy/coverart.py b/musicbrainzpy/coverart.py new file mode 100644 index 0000000..5374d6e --- /dev/null +++ b/musicbrainzpy/coverart.py @@ -0,0 +1,285 @@ +"""Cover Art Archive client. + +Async and sync clients for the Cover Art Archive (coverartarchive.org). +Separate from the main MusicBrainz API — different host, no auth required. +""" + +from __future__ import annotations + +from typing import Literal + +import httpx + +from musicbrainzpy._retry import DEFAULT_BASE_DELAY, DEFAULT_MAX_RETRIES, async_retry, sync_retry +from musicbrainzpy.client import _build_user_agent, _raise_for_status +from musicbrainzpy.models.coverart import CoverArtImageList + +DEFAULT_CAA_BASE_URL = "https://coverartarchive.org/" + +ImageSize = Literal[250, 500, 1200] + + +def _image_path(entity_type: str, mbid: str, image_id: str, size: ImageSize | None = None) -> str: + """Build the URL path for an image request.""" + suffix = f"-{size}" if size else "" + return f"{entity_type}/{mbid}/{image_id}{suffix}" + + +class CoverArtClient: + """Async client for the Cover Art Archive. + + Args: + app_name: Application name for User-Agent. + app_version: Application version for User-Agent. + app_contact: Contact URL or email for User-Agent. + base_url: CAA base URL. Defaults to the official endpoint. + """ + + def __init__( + self, + app_name: str, + app_version: str, + app_contact: str, + *, + base_url: str = DEFAULT_CAA_BASE_URL, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base_delay: float = DEFAULT_BASE_DELAY, + ) -> None: + self._base_url = base_url.rstrip("/") + "/" + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._client = httpx.AsyncClient( + headers={"User-Agent": _build_user_agent(app_name, app_version, app_contact)}, + follow_redirects=True, + ) + + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.aclose() + + async def __aenter__(self) -> CoverArtClient: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + async def _get_json(self, path: str) -> dict: + """GET a JSON response.""" + + async def _do() -> dict: + response = await self._client.get(self._base_url + path, headers={"Accept": "application/json"}) + _raise_for_status(response) + return response.json() + + return await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) + + async def _get_bytes(self, path: str) -> bytes: + """GET binary image data.""" + + async def _do() -> bytes: + response = await self._client.get(self._base_url + path) + _raise_for_status(response) + return response.content + + return await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) + + async def _head(self, url: str) -> httpx.Response: + """HEAD request (follows redirects).""" + + async def _do() -> httpx.Response: + response = await self._client.head(url) + _raise_for_status(response) + return response + + return await async_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) + + # --- JSON listings --- + + async def get_image_list(self, release_id: str) -> CoverArtImageList: + """Get the list of cover art for a release. + + Args: + release_id: MusicBrainz release MBID. + """ + data = await self._get_json(f"release/{release_id}/") + return CoverArtImageList.model_validate(data) + + async def get_release_group_image_list(self, release_group_id: str) -> CoverArtImageList: + """Get the list of cover art for a release group. + + Args: + release_group_id: MusicBrainz release group MBID. + """ + data = await self._get_json(f"release-group/{release_group_id}/") + return CoverArtImageList.model_validate(data) + + # --- Binary image downloads --- + + async def get_image( + self, mbid: str, cover_id: str, *, size: ImageSize | None = None, entity_type: str = "release" + ) -> bytes: + """Download a specific cover art image. + + Args: + mbid: MusicBrainz release or release group MBID. + cover_id: Image ID from the listing, or ``"front"``/``"back"``. + size: Thumbnail size (250, 500, 1200) or None for full-size. + entity_type: ``"release"`` or ``"release-group"``. + """ + return await self._get_bytes(_image_path(entity_type, mbid, cover_id, size)) + + async def get_front(self, release_id: str, *, size: ImageSize | None = None) -> bytes: + """Download the front cover art for a release. + + Args: + release_id: MusicBrainz release MBID. + size: Thumbnail size (250, 500, 1200) or None for full-size. + """ + return await self.get_image(release_id, "front", size=size) + + async def get_back(self, release_id: str, *, size: ImageSize | None = None) -> bytes: + """Download the back cover art for a release. + + Args: + release_id: MusicBrainz release MBID. + size: Thumbnail size (250, 500, 1200) or None for full-size. + """ + return await self.get_image(release_id, "back", size=size) + + async def get_release_group_front(self, release_group_id: str, *, size: ImageSize | None = None) -> bytes: + """Download the front cover art for a release group. + + Args: + release_group_id: MusicBrainz release group MBID. + size: Thumbnail size (250, 500, 1200) or None for full-size. + """ + return await self.get_image(release_group_id, "front", size=size, entity_type="release-group") + + async def image_info( + self, + mbid: str, + cover_id: str, + *, + size: ImageSize | None = None, + entity_type: str = "release", + ) -> dict[str, str | int | None]: + """Get metadata for an image via HEAD request (content-type, content-length). + + Args: + mbid: MusicBrainz release or release group MBID. + cover_id: Image ID from the listing, or ``"front"``/``"back"``. + size: Thumbnail size (250, 500, 1200) or None for full-size. + entity_type: ``"release"`` or ``"release-group"``. + """ + response = await self._head(self._base_url + _image_path(entity_type, mbid, cover_id, size)) + return { + "content_type": response.headers.get("content-type"), + "content_length": int(cl) if (cl := response.headers.get("content-length")) else None, + } + + +class SyncCoverArtClient: + """Synchronous client for the Cover Art Archive. + + Args: + app_name: Application name for User-Agent. + app_version: Application version for User-Agent. + app_contact: Contact URL or email for User-Agent. + base_url: CAA base URL. Defaults to the official endpoint. + """ + + def __init__( + self, + app_name: str, + app_version: str, + app_contact: str, + *, + base_url: str = DEFAULT_CAA_BASE_URL, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base_delay: float = DEFAULT_BASE_DELAY, + ) -> None: + self._base_url = base_url.rstrip("/") + "/" + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._client = httpx.Client( + headers={"User-Agent": _build_user_agent(app_name, app_version, app_contact)}, + follow_redirects=True, + ) + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._client.close() + + def __enter__(self) -> SyncCoverArtClient: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def _get_json(self, path: str) -> dict: + """GET a JSON response.""" + + def _do() -> dict: + response = self._client.get(self._base_url + path, headers={"Accept": "application/json"}) + _raise_for_status(response) + return response.json() + + return sync_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) + + def _get_bytes(self, path: str) -> bytes: + """GET binary image data.""" + + def _do() -> bytes: + response = self._client.get(self._base_url + path) + _raise_for_status(response) + return response.content + + return sync_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) + + def _head(self, url: str) -> httpx.Response: + """HEAD request (follows redirects).""" + + def _do() -> httpx.Response: + response = self._client.head(url) + _raise_for_status(response) + return response + + return sync_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) + + def get_image_list(self, release_id: str) -> CoverArtImageList: + """Get the list of cover art for a release. See :meth:`CoverArtClient.get_image_list`.""" + data = self._get_json(f"release/{release_id}/") + return CoverArtImageList.model_validate(data) + + def get_release_group_image_list(self, release_group_id: str) -> CoverArtImageList: + """Get the list of cover art for a release group. See :meth:`CoverArtClient.get_release_group_image_list`.""" + data = self._get_json(f"release-group/{release_group_id}/") + return CoverArtImageList.model_validate(data) + + def get_image( + self, mbid: str, cover_id: str, *, size: ImageSize | None = None, entity_type: str = "release" + ) -> bytes: + """Download a specific cover art image. See :meth:`CoverArtClient.get_image`.""" + return self._get_bytes(_image_path(entity_type, mbid, cover_id, size)) + + def get_front(self, release_id: str, *, size: ImageSize | None = None) -> bytes: + """Download the front cover art for a release. See :meth:`CoverArtClient.get_front`.""" + return self.get_image(release_id, "front", size=size) + + def get_back(self, release_id: str, *, size: ImageSize | None = None) -> bytes: + """Download the back cover art for a release. See :meth:`CoverArtClient.get_back`.""" + return self.get_image(release_id, "back", size=size) + + def get_release_group_front(self, release_group_id: str, *, size: ImageSize | None = None) -> bytes: + """Download the front cover art for a release group. See :meth:`CoverArtClient.get_release_group_front`.""" + return self.get_image(release_group_id, "front", size=size, entity_type="release-group") + + def image_info( + self, mbid: str, cover_id: str, *, size: ImageSize | None = None, entity_type: str = "release" + ) -> dict[str, str | int | None]: + """Get metadata for an image via HEAD request. See :meth:`CoverArtClient.image_info`.""" + response = self._head(self._base_url + _image_path(entity_type, mbid, cover_id, size)) + return { + "content_type": response.headers.get("content-type"), + "content_length": int(cl) if (cl := response.headers.get("content-length")) else None, + } diff --git a/musicbrainzpy/enums.py b/musicbrainzpy/enums.py index 2067c5b..3f45d62 100644 --- a/musicbrainzpy/enums.py +++ b/musicbrainzpy/enums.py @@ -2,10 +2,14 @@ from __future__ import annotations -from enum import StrEnum +from enum import Enum -class EntityType(StrEnum): +class _StrEnum(str, Enum): + """str-valued enum compatible with Python 3.10+.""" + + +class EntityType(_StrEnum): """Core MusicBrainz entity types.""" AREA = "area" @@ -23,7 +27,7 @@ class EntityType(StrEnum): URL = "url" -class ReleaseStatus(StrEnum): +class ReleaseStatus(_StrEnum): OFFICIAL = "official" PROMOTION = "promotion" BOOTLEG = "bootleg" @@ -32,7 +36,7 @@ class ReleaseStatus(StrEnum): CANCELLED = "cancelled" -class ReleaseGroupType(StrEnum): +class ReleaseGroupType(_StrEnum): ALBUM = "album" SINGLE = "single" EP = "ep" @@ -40,7 +44,7 @@ class ReleaseGroupType(StrEnum): OTHER = "other" -class ReleaseGroupSecondaryType(StrEnum): +class ReleaseGroupSecondaryType(_StrEnum): AUDIO_DRAMA = "audio drama" AUDIOBOOK = "audiobook" COMPILATION = "compilation" @@ -55,7 +59,7 @@ class ReleaseGroupSecondaryType(StrEnum): SPOKENWORD = "spokenword" -class OAuthScope(StrEnum): +class OAuthScope(_StrEnum): """OAuth2 scopes for MusicBrainz API authorization.""" PROFILE = "profile" diff --git a/musicbrainzpy/exceptions.py b/musicbrainzpy/exceptions.py index dd027e3..b088650 100644 --- a/musicbrainzpy/exceptions.py +++ b/musicbrainzpy/exceptions.py @@ -12,7 +12,11 @@ class NotFoundError(MusicBrainzError): class RateLimitedError(MusicBrainzError): - """Rate limit exceeded (HTTP 503).""" + """Rate limit exceeded (HTTP 429 or 503).""" + + def __init__(self, message: str, retry_after: float | None = None) -> None: + super().__init__(message) + self.retry_after = retry_after class AuthenticationError(MusicBrainzError): diff --git a/musicbrainzpy/models/__init__.py b/musicbrainzpy/models/__init__.py index 1ac8dca..0a55828 100644 --- a/musicbrainzpy/models/__init__.py +++ b/musicbrainzpy/models/__init__.py @@ -19,6 +19,7 @@ Tag, TextRepresentation, ) +from musicbrainzpy.models.coverart import CoverArtImage, CoverArtImageList, Thumbnails from musicbrainzpy.models.event import Event from musicbrainzpy.models.genre import GenreFull from musicbrainzpy.models.instrument import Instrument @@ -42,6 +43,8 @@ "Collection", "Coordinates", "CoverArtArchive", + "CoverArtImage", + "CoverArtImageList", "Event", "Genre", "GenreFull", @@ -62,6 +65,7 @@ "Series", "Tag", "TextRepresentation", + "Thumbnails", "Track", "Url", "Work", diff --git a/musicbrainzpy/models/coverart.py b/musicbrainzpy/models/coverart.py new file mode 100644 index 0000000..c785fd9 --- /dev/null +++ b/musicbrainzpy/models/coverart.py @@ -0,0 +1,38 @@ +"""Cover Art Archive models.""" + +from __future__ import annotations + +from pydantic import Field + +from musicbrainzpy.models.common import MBModel + + +class Thumbnails(MBModel): + """Thumbnail URLs for a cover art image.""" + + small: str | None = None + large: str | None = None + t250: str | None = Field(default=None, alias="250") + t500: str | None = Field(default=None, alias="500") + t1200: str | None = Field(default=None, alias="1200") + + +class CoverArtImage(MBModel): + """A single cover art image entry.""" + + id: int | str + types: list[str] = Field(default_factory=list) + front: bool = False + back: bool = False + image: str + thumbnails: Thumbnails + comment: str = "" + approved: bool = False + edit: int | None = None + + +class CoverArtImageList(MBModel): + """JSON listing of cover art for a release or release group.""" + + images: list[CoverArtImage] = Field(default_factory=list) + release: str diff --git a/musicbrainzpy/sync_client.py b/musicbrainzpy/sync_client.py index d00b109..5d9fd36 100644 --- a/musicbrainzpy/sync_client.py +++ b/musicbrainzpy/sync_client.py @@ -2,14 +2,17 @@ from __future__ import annotations +import os from collections.abc import Mapping from typing import Any import httpx from musicbrainzpy._ratelimit import SyncRateLimiter +from musicbrainzpy._retry import DEFAULT_BASE_DELAY, DEFAULT_MAX_RETRIES, sync_retry from musicbrainzpy.auth import make_digest_auth from musicbrainzpy.client import ( + _ENV_PREFIX, DEFAULT_BASE_URL, BrowseResult, SearchResult, @@ -31,23 +34,40 @@ class SyncMusicBrainzClient: def __init__( self, - app_name: str, - app_version: str, - app_contact: str, + app_name: str | None = None, + app_version: str | None = None, + app_contact: str | None = None, *, - base_url: str = DEFAULT_BASE_URL, + base_url: str | None = None, rate_limit: float = 1.0, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base_delay: float = DEFAULT_BASE_DELAY, username: str | None = None, password: str | None = None, ) -> None: - self._base_url = base_url.rstrip("/") + "/" + _app = app_name or os.environ.get(f"{_ENV_PREFIX}APP") + _ver = app_version or os.environ.get(f"{_ENV_PREFIX}VERSION") + _contact = app_contact or os.environ.get(f"{_ENV_PREFIX}CONTACT") + if not (_app and _ver and _contact): + msg = ( + "app_name, app_version, and app_contact are required " + "(pass them directly or set MUSICBRAINZPY_APP, MUSICBRAINZPY_VERSION, MUSICBRAINZPY_CONTACT)" + ) + raise ValueError(msg) + _base = base_url or os.environ.get(f"{_ENV_PREFIX}BASE_URL") or DEFAULT_BASE_URL + _user = username or os.environ.get(f"{_ENV_PREFIX}USERNAME") + _pass = password or os.environ.get(f"{_ENV_PREFIX}PASSWORD") + self._base_url = _base.rstrip("/") + "/" self._rate_limiter = SyncRateLimiter(interval=rate_limit) - self._digest_auth = make_digest_auth(username, password) if username and password else None + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._digest_auth = make_digest_auth(_user, _pass) if _user and _pass else None self._client = httpx.Client( headers={ - "User-Agent": _build_user_agent(app_name, app_version, app_contact), + "User-Agent": _build_user_agent(_app, _ver, _contact), "Accept": "application/json", }, + follow_redirects=True, ) def close(self) -> None: @@ -62,12 +82,16 @@ def __exit__(self, *exc: object) -> None: def _get(self, path: str, params: Mapping[str, str | list[str]] | None = None) -> dict[str, Any]: """Perform a rate-limited GET request and return parsed JSON.""" - self._rate_limiter.acquire() - url = self._base_url + path - auth = self._digest_auth if self._digest_auth else None - response = self._client.get(url, params=params, auth=auth) - _raise_for_status(response) - return response.json() + + def _do() -> dict[str, Any]: + self._rate_limiter.acquire() + url = self._base_url + path + auth = self._digest_auth if self._digest_auth else None + response = self._client.get(url, params=params, auth=auth) + _raise_for_status(response) + return response.json() + + return sync_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) def _get_authenticated(self, path: str, params: Mapping[str, str | list[str]] | None = None) -> dict[str, Any]: """Perform a rate-limited authenticated GET request. @@ -77,11 +101,15 @@ def _get_authenticated(self, path: str, params: Mapping[str, str | list[str]] | """ if not self._digest_auth: raise AuthenticationError("Authentication required. Provide username/password.") - self._rate_limiter.acquire() - url = self._base_url + path - response = self._client.get(url, params=params, auth=self._digest_auth) - _raise_for_status(response) - return response.json() + + def _do() -> dict[str, Any]: + self._rate_limiter.acquire() + url = self._base_url + path + response = self._client.get(url, params=params, auth=self._digest_auth) + _raise_for_status(response) + return response.json() + + return sync_retry(_do, max_retries=self._max_retries, base_delay=self._retry_base_delay) # --- Raw methods --- diff --git a/pyproject.toml b/pyproject.toml index cb767eb..7ce3086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,16 @@ version = "0.1.0" description = "Modern Python bindings for the MusicBrainz JSON API" readme = "README.md" license = "GPL-3.0-or-later" -requires-python = ">=3.12" +requires-python = ">=3.10" keywords = ["musicbrainz", "music", "metadata", "api", "json"] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Topic :: Multimedia :: Sound/Audio", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Typing :: Typed", ] dependencies = [ diff --git a/tests/test_client.py b/tests/test_client.py index e914e8a..f566e4b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -53,6 +53,7 @@ def test_success_does_nothing(self) -> None: (400, InvalidRequestError), (401, AuthenticationError), (404, NotFoundError), + (429, RateLimitedError), (503, RateLimitedError), ], ) @@ -61,6 +62,18 @@ def test_known_errors(self, status: int, exc_class: type[MusicBrainzError]) -> N with pytest.raises(exc_class, match=f"HTTP {status}"): _raise_for_status(response) + def test_rate_limited_retry_after(self) -> None: + response = httpx.Response(429, text="slow down", headers={"Retry-After": "5"}) + with pytest.raises(RateLimitedError) as exc_info: + _raise_for_status(response) + assert exc_info.value.retry_after == 5.0 + + def test_rate_limited_no_retry_after(self) -> None: + response = httpx.Response(503, text="overloaded") + with pytest.raises(RateLimitedError) as exc_info: + _raise_for_status(response) + assert exc_info.value.retry_after is None + def test_unknown_error(self) -> None: response = httpx.Response(500, text="server error") with pytest.raises(MusicBrainzError, match="HTTP 500"): @@ -91,6 +104,29 @@ async def test_context_manager(self) -> None: assert c._client.is_closed is False assert c._client.is_closed is True + def test_missing_ua_raises(self) -> None: + with pytest.raises(ValueError, match="app_name, app_version, and app_contact are required"): + MusicBrainzClient() + + def test_env_var_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MUSICBRAINZPY_APP", "envapp") + monkeypatch.setenv("MUSICBRAINZPY_VERSION", "2.0") + monkeypatch.setenv("MUSICBRAINZPY_CONTACT", "env@test.com") + c = MusicBrainzClient(rate_limit=0) + assert c._client.headers["user-agent"] == "envapp/2.0 ( env@test.com )" + + def test_env_var_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MUSICBRAINZPY_BASE_URL", "https://mirror.example.com/ws/2") + c = MusicBrainzClient("a", "1", "x", rate_limit=0) + assert c._base_url == "https://mirror.example.com/ws/2/" + + def test_explicit_args_override_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MUSICBRAINZPY_APP", "envapp") + monkeypatch.setenv("MUSICBRAINZPY_VERSION", "2.0") + monkeypatch.setenv("MUSICBRAINZPY_CONTACT", "env@test.com") + c = MusicBrainzClient("myapp", "1.0", "me@test.com", rate_limit=0) + assert c._client.headers["user-agent"] == "myapp/1.0 ( me@test.com )" + class TestLookup: async def test_lookup_artist(self, client: MusicBrainzClient, mock_api: respx.MockRouter) -> None: diff --git a/tests/test_coverart.py b/tests/test_coverart.py new file mode 100644 index 0000000..29afa1a --- /dev/null +++ b/tests/test_coverart.py @@ -0,0 +1,143 @@ +"""Tests for the Cover Art Archive client.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from musicbrainzpy.coverart import CoverArtClient, SyncCoverArtClient +from musicbrainzpy.exceptions import NotFoundError +from musicbrainzpy.models.coverart import CoverArtImageList + +RELEASE_MBID = "76df3287-6cda-33eb-8e9a-044b5e15ffdd" +RG_MBID = "c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932" + +IMAGE_LIST_RESPONSE = { + "images": [ + { + "types": ["Front"], + "front": True, + "back": False, + "edit": 17462565, + "image": f"http://coverartarchive.org/release/{RELEASE_MBID}/829521842.jpg", + "comment": "", + "approved": True, + "id": 829521842, + "thumbnails": { + "250": f"http://coverartarchive.org/release/{RELEASE_MBID}/829521842-250.jpg", + "500": f"http://coverartarchive.org/release/{RELEASE_MBID}/829521842-500.jpg", + "1200": f"http://coverartarchive.org/release/{RELEASE_MBID}/829521842-1200.jpg", + "small": f"http://coverartarchive.org/release/{RELEASE_MBID}/829521842-250.jpg", + "large": f"http://coverartarchive.org/release/{RELEASE_MBID}/829521842-500.jpg", + }, + } + ], + "release": f"http://musicbrainz.org/release/{RELEASE_MBID}", +} + +DUMMY_IMAGE = b"\x89PNG\r\n\x1a\n" + + +@pytest.fixture +def caa_client() -> CoverArtClient: + return CoverArtClient("test-app", "0.1.0", "test@example.com") + + +@pytest.fixture +def mock_caa() -> respx.MockRouter: + """Context-managed respx mock for the CAA.""" + # We use a non-context-manager approach so pytest can manage it + return respx.MockRouter(base_url="https://coverartarchive.org") + + +class TestCoverArtModels: + def test_deserialize_image_list(self) -> None: + result = CoverArtImageList.model_validate(IMAGE_LIST_RESPONSE) + assert len(result.images) == 1 + img = result.images[0] + assert img.front is True + assert img.back is False + assert img.types == ["Front"] + assert img.id == 829521842 + assert img.thumbnails.t250 is not None + assert img.thumbnails.t500 is not None + assert img.thumbnails.t1200 is not None + assert img.thumbnails.small is not None + + +class TestCoverArtClient: + async def test_get_image_list(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/").mock(return_value=httpx.Response(200, json=IMAGE_LIST_RESPONSE)) + result = await caa_client.get_image_list(RELEASE_MBID) + assert isinstance(result, CoverArtImageList) + assert len(result.images) == 1 + assert result.images[0].front is True + + async def test_get_release_group_image_list(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release-group/{RG_MBID}/").mock(return_value=httpx.Response(200, json=IMAGE_LIST_RESPONSE)) + result = await caa_client.get_release_group_image_list(RG_MBID) + assert isinstance(result, CoverArtImageList) + + async def test_get_front(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/front").mock(return_value=httpx.Response(200, content=DUMMY_IMAGE)) + data = await caa_client.get_front(RELEASE_MBID) + assert data == DUMMY_IMAGE + + async def test_get_front_with_size(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/front-500").mock(return_value=httpx.Response(200, content=DUMMY_IMAGE)) + data = await caa_client.get_front(RELEASE_MBID, size=500) + assert data == DUMMY_IMAGE + + async def test_get_back(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/back").mock(return_value=httpx.Response(200, content=DUMMY_IMAGE)) + data = await caa_client.get_back(RELEASE_MBID) + assert data == DUMMY_IMAGE + + async def test_get_image_by_id(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/829521842").mock(return_value=httpx.Response(200, content=DUMMY_IMAGE)) + data = await caa_client.get_image(RELEASE_MBID, "829521842") + assert data == DUMMY_IMAGE + + async def test_get_image_by_id_with_size(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/829521842-250").mock( + return_value=httpx.Response(200, content=DUMMY_IMAGE) + ) + data = await caa_client.get_image(RELEASE_MBID, "829521842", size=250) + assert data == DUMMY_IMAGE + + async def test_get_release_group_front(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release-group/{RG_MBID}/front").mock(return_value=httpx.Response(200, content=DUMMY_IMAGE)) + data = await caa_client.get_release_group_front(RG_MBID) + assert data == DUMMY_IMAGE + + async def test_not_found(self, caa_client: CoverArtClient) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/").mock(return_value=httpx.Response(404, text="Not Found")) + with pytest.raises(NotFoundError): + await caa_client.get_image_list(RELEASE_MBID) + + +class TestSyncCoverArtClient: + def test_get_image_list(self) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/").mock(return_value=httpx.Response(200, json=IMAGE_LIST_RESPONSE)) + with SyncCoverArtClient("test", "0.1", "test@example.com") as c: + result = c.get_image_list(RELEASE_MBID) + assert isinstance(result, CoverArtImageList) + assert len(result.images) == 1 + + def test_get_front(self) -> None: + with respx.mock(base_url="https://coverartarchive.org") as mock: + mock.get(f"/release/{RELEASE_MBID}/front").mock(return_value=httpx.Response(200, content=DUMMY_IMAGE)) + with SyncCoverArtClient("test", "0.1", "test@example.com") as c: + data = c.get_front(RELEASE_MBID) + assert data == DUMMY_IMAGE diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..1bc5181 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,99 @@ +"""Tests for the retry module.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import httpx +import pytest + +from musicbrainzpy._retry import async_retry, sync_retry +from musicbrainzpy.exceptions import NotFoundError, RateLimitedError + + +class TestAsyncRetry: + async def test_success_no_retry(self) -> None: + func = MagicMock(return_value=42) + + async def _call() -> int: + return func() + + result = await async_retry(_call, max_retries=3, base_delay=0) + assert result == 42 + assert func.call_count == 1 + + async def test_retries_on_transport_error(self) -> None: + func = MagicMock(side_effect=[httpx.ConnectError("fail"), httpx.ReadTimeout("timeout"), 42]) + + async def _call() -> int: + return func() + + result = await async_retry(_call, max_retries=3, base_delay=0) + assert result == 42 + assert func.call_count == 3 + + async def test_retries_on_rate_limit(self) -> None: + func = MagicMock(side_effect=[RateLimitedError("429", retry_after=0), 42]) + + async def _call() -> int: + return func() + + result = await async_retry(_call, max_retries=3, base_delay=0) + assert result == 42 + assert func.call_count == 2 + + async def test_no_retry_on_permanent_error(self) -> None: + func = MagicMock(side_effect=NotFoundError("404")) + + async def _call() -> int: + return func() + + with pytest.raises(NotFoundError): + await async_retry(_call, max_retries=3, base_delay=0) + assert func.call_count == 1 + + async def test_exhausted_retries_raises(self) -> None: + func = MagicMock(side_effect=httpx.ConnectError("fail")) + + async def _call() -> int: + return func() + + with pytest.raises(httpx.ConnectError): + await async_retry(_call, max_retries=2, base_delay=0) + assert func.call_count == 3 # initial + 2 retries + + async def test_zero_retries_no_retry(self) -> None: + func = MagicMock(side_effect=httpx.ConnectError("fail")) + + async def _call() -> int: + return func() + + with pytest.raises(httpx.ConnectError): + await async_retry(_call, max_retries=0, base_delay=0) + assert func.call_count == 1 + + +class TestSyncRetry: + def test_success_no_retry(self) -> None: + func = MagicMock(return_value=42) + result = sync_retry(func, max_retries=3, base_delay=0) + assert result == 42 + assert func.call_count == 1 + + def test_retries_on_transport_error(self) -> None: + func = MagicMock(side_effect=[httpx.ConnectError("fail"), 42]) + result = sync_retry(func, max_retries=3, base_delay=0) + assert result == 42 + assert func.call_count == 2 + + def test_no_retry_on_permanent_error(self) -> None: + func = MagicMock(side_effect=NotFoundError("404")) + with pytest.raises(NotFoundError): + sync_retry(func, max_retries=3, base_delay=0) + assert func.call_count == 1 + + def test_exhausted_retries_raises(self) -> None: + func = MagicMock(side_effect=RateLimitedError("503", retry_after=0)) + with pytest.raises(RateLimitedError): + sync_retry(func, max_retries=1, base_delay=0) + assert func.call_count == 2 # initial + 1 retry diff --git a/uv.lock b/uv.lock index a0cad6d..02bb02f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.10" [[package]] name = "annotated-types" @@ -16,6 +16,7 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -24,6 +25,15 @@ 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 = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -60,6 +70,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "filelock" version = "3.25.2" @@ -244,6 +266,33 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, @@ -300,10 +349,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] @@ -321,10 +390,12 @@ version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ @@ -336,6 +407,7 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -363,6 +435,24 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -440,6 +530,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "ty" version = "0.0.24" @@ -494,6 +638,7 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [